Compare commits

...

24 Commits

Author SHA1 Message Date
Lucas Santana
bb85c83c5b Dashboard
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-02-07 12:03:46 -03:00
Lucas Santana
2175458186 feat: implementa store global de métricas e corrige processamento de dados
- Adiciona store global usando Zustand para gerenciamento de métricas
- Implementa funções específicas para atualização de métricas
- Corrige processamento de métricas semanais
- Melhora manipulação de estados e performance
- Resolve problema de dados vazios nos gráficos
2025-02-07 11:04:49 -03:00
Lucas Santana
190777dcd0 feat: adiciona componentes de visualização para métricas de escrita
Cria WritingMetricsSection para exibição de cards de métricas
Cria WritingMetricsChart para visualização da evolução
Integra novos componentes ao StudentDashboardPage
Mantém consistência visual com métricas de leitura
type: feat
scope: metrics
breaking: false
2025-02-07 10:55:24 -03:00
Lucas Santana
8c6e6aedd3 feat: separa estrutura de dados para métricas de leitura e escrita
- Cria novo arquivo de tipos para métricas
- Refatora interfaces para separar métricas de leitura e escrita
- Atualiza StudentDashboardPage para usar novas interfaces
- Prepara estrutura para implementação das métricas de escrita

type: feat
scope: metrics
breaking: false
2025-02-07 10:50:10 -03:00
Lucas Santana
8b45fe72e7 refactor: Refatorando estilo da Essay Analysis 2025-02-07 10:43:40 -03:00
Lucas Santana
ccbac66d28 feat: adiciona novos recursos de formatação e tracking no editor 2025-02-07 10:32:28 -03:00
Lucas Santana
46e8ba0312 fix: corrige fluxo de redações e visualização pós-análise - Corrige carregamento do conteúdo após envio - Adiciona salvamento automático antes da análise - Melhora UX com feedback visual e badges 2025-02-07 10:20:48 -03:00
Lucas Santana
c94c46f5c1 fix: corrige consulta de análise de redações - Adiciona join com tabelas relacionadas - Implementa transformação dos dados - Adiciona tratamento para valores nulos 2025-02-07 10:06:27 -03:00
Lucas Santana
28ac3ef8cc refactor: simplifica validação do JSON Schema da análise de redações - Remove limites min/max dos campos numéricos - Remove restrição minItems dos arrays - Simplifica validação para maior flexibilidade 2025-02-07 10:04:54 -03:00
Lucas Santana
756335f78f fix: corrige políticas RLS para análise de redações - Simplifica política de inserção para service_role - Adiciona políticas para tabelas relacionadas - Melhora segurança com políticas específicas 2025-02-07 09:42:25 -03:00
Lucas Santana
9d303b0c7a refactor: normaliza JSON Schema da análise de redações - Reordena campos para corresponder à estrutura do banco de dados - Ajusta descrições dos campos para maior clareza - Alinha com as tabelas: essay_analyses e relacionadas - Melhora validação dos dados com JSON Schema mais preciso 2025-02-07 09:37:15 -03:00
Lucas Santana
0eafbd5350 feat: melhora logs e tratamento de erros na análise de redações
- Adiciona logs detalhados em cada etapa do processo
- Melhora validação das variáveis de ambiente
- Implementa tratamento de erros mais robusto
- Padroniza formato de respostas de erro
- Refina schema de validação da OpenAI
2025-02-06 22:13:14 -03:00
Lucas Santana
4609217fb7 feat: Corrigindo analyze-essay 2025-02-06 21:59:18 -03:00
Lucas Santana
1c6aa56b32 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
2025-02-06 21:54:01 -03:00
Lucas Santana
2929946499 feat: implementa editor de texto rico com TipTap
- Adiciona editor WYSIWYG com formatação básica
- Implementa contagem de palavras em tempo real
- Adiciona barra de ferramentas de formatação
- Suporte a alinhamento de texto e destaque
2025-02-06 21:44:56 -03:00
Lucas Santana
1bc307d599 style: padroniza visual do editor de redações
- Alinha estilo com StoryPage e CreateStoryPage
- Adiciona suporte a texto adaptativo
- Melhora feedback visual e estados interativos
- Implementa loading states animados
2025-02-06 21:41:08 -03:00
Lucas Santana
e9005e429f fix: corrige erro de undefined em NewEssay
- Adiciona verificação de segurança para requirements
- Implementa valores padrão para min/max words
- Adiciona renderização condicional para elementos necessários
2025-02-06 21:39:02 -03:00
Lucas Santana
b767d60c50 style: padroniza visual da criação de redações
- Alinha estilo dos cards com CreateStoryPage
- Adiciona suporte a texto adaptativo
- Melhora feedback visual e estados interativos
- Implementa loading states animados
2025-02-06 21:38:01 -03:00
Lucas Santana
63498e92c6 feat: adiciona política RLS para deleção de redações
- Permite que alunos deletem apenas suas próprias redações
- Mantém consistência com outras políticas RLS existentes
- Adiciona rollback apropriado para a nova política
2025-02-06 21:34:17 -03:00
Lucas Santana
cc45bb974d style: ajusta visual e animações do AlertDialog
- Reduz opacidade do overlay para 50%
- Simplifica animações mantendo apenas fade in/out
- Aumenta duração da transição para 300ms
2025-02-06 21:30:20 -03:00
Lucas Santana
da62f5e722 refactor: atualiza dialog de confirmação para AlertDialog no EssayPage
- Substitui Dialog básico pelo AlertDialog especializado
- Melhora feedback visual na confirmação de deleção
- Mantém consistência com o design system
- Implementa padrões de acessibilidade do Radix UI
2025-02-06 21:26:46 -03:00
Lucas Santana
d1e44f84b7 feat: Implementando páginas de essays 2025-02-06 20:44:41 -03:00
Lucas Santana
f602f4c666 feat: Implementando Edge Function Analyze-Essay 2025-02-06 20:22:30 -03:00
Lucas Santana
206f7bcb30 feat: Criando tabelas para nova funcionalidade de correção de redação 2025-02-06 20:06:25 -03:00
35 changed files with 5825 additions and 514 deletions

View File

@ -250,3 +250,77 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
- Configuração centralizada de métricas
- Suporte a tooltips e ícones personalizados
- Responsividade e acessibilidade melhoradas
### Técnico
- Normalização do JSON Schema da análise de redações para corresponder à estrutura do banco de dados
- Reordenação dos campos para corresponder à estrutura das tabelas
- Ajuste nas descrições dos campos para maior clareza
- Alinhamento com as tabelas: essay_analyses, essay_analysis_feedback, essay_analysis_strengths e essay_analysis_improvements
- Melhoria na validação dos dados com JSON Schema mais preciso
### Técnico
- Correção das políticas de segurança (RLS) para o sistema de análise de redações:
- Simplificada a política de inserção para service_role
- Adicionadas políticas para tabelas relacionadas (feedback, pontos fortes, melhorias e notas)
- Melhorada a segurança com políticas específicas para cada operação
- Corrigido erro de permissão na inserção de análises pela Edge Function
### Técnico
- Removidas restrições de validação do JSON Schema da análise de redações:
- Removidos limites `minimum` e `maximum` dos campos numéricos
- Removida restrição `minItems` dos arrays de pontos fortes e melhorias
- Simplificada a validação para maior flexibilidade na Edge Function
### Técnico
- Corrigida consulta de análise de redações no componente `EssayAnalysis`:
- Adicionado join com tabelas relacionadas (feedback, strengths, improvements, scores)
- Implementada transformação dos dados para o formato esperado
- Adicionado tratamento para valores nulos
- Melhorada tipagem dos dados retornados
### Modificado
- Melhorado o fluxo de redações:
- Corrigido carregamento do conteúdo da redação após envio para análise
- Adicionado salvamento automático do conteúdo antes de enviar para análise
- Melhorada visualização do status 'analisada' com badge verde
- Adicionado botão "Ver Análise" para redações analisadas
- Ajustado Editor para modo somente leitura após envio
- Melhorada contagem de palavras em todos os estados da redação
### Técnico
- Refatorado componente `EssayPage`:
- Adicionada lógica de salvamento antes do envio para análise
- Melhorada query do Supabase para incluir conteúdo explicitamente
- Implementado feedback visual durante operações de salvamento
- Otimizado carregamento inicial da redação
- Adicionado tratamento de estados para diferentes status da redação
## [0.2.0] - 2024-03-21
### Adicionado
- Novos recursos de formatação no editor:
- Tachado (strike-through)
- Código inline
- Lista com marcadores
- Lista numerada
- Citação (blockquote)
- Rastreamento de eventos (tracking) em todos os botões do editor
### Modificado
- Melhorias no fluxo de redações:
- Carregamento correto do conteúdo após submissão para análise
- Salvamento automático do conteúdo antes da submissão
- Badge verde para status "analisada"
- Botão "Ver Análise" para redações analisadas
- Editor em modo somente leitura após submissão
- Contagem de palavras em todos os estados da redação
### Técnico
- Refatoração do componente Editor para incluir novos recursos de formatação
- Adição de trackingId em todos os botões para análise de uso
- Melhorias de acessibilidade com aria-labels em português
- Refatoração do EssayPage para incluir lógica de salvamento antes da submissão para análise
- Melhoria na query do Supabase para incluir conteúdo explicitamente
- Implementação de feedback visual durante operações de salvamento
- Otimização do carregamento inicial da redação
- Adição de tratamento de estado para diferentes status da redação

1244
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -23,14 +23,27 @@
"@opentelemetry/sdk-metrics": "^1.30.1",
"@opentelemetry/sdk-trace-web": "^1.30.1",
"@radix-ui/react-accordion": "^1.2.2",
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-progress": "^1.1.1",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.2",
"@radix-ui/react-toast": "^1.2.4",
"@sentry/react": "^8.48.0",
"@supabase/supabase-js": "^2.39.7",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "^5.62.8",
"@testing-library/react": "^16.1.0",
"@tiptap/extension-character-count": "^2.11.5",
"@tiptap/extension-color": "^2.11.5",
"@tiptap/extension-highlight": "^2.11.5",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/extension-text-align": "^2.11.5",
"@tiptap/extension-text-style": "^2.11.5",
"@tiptap/extension-underline": "^2.11.5",
"@tiptap/pm": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@tremor/react": "^3.18.7",
"@types/ioredis": "^4.28.10",
"@types/jest": "^29.5.14",
@ -48,11 +61,14 @@
"resend": "^3.2.0",
"shadcn-ui": "^0.9.4",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"uuid": "^11.0.3",
"vitest": "^2.1.8"
"vitest": "^2.1.8",
"zustand": "^5.0.3"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@shadcn/ui": "^0.0.4",
"@testing-library/jest-dom": "^6.6.3",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",

View File

@ -1,18 +1,7 @@
import React from 'react';
import { Calendar, HelpCircle } from 'lucide-react';
import { Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Bar, ComposedChart } from 'recharts';
interface WeeklyMetrics {
week: string;
fluency: number;
pronunciation: number;
accuracy: number;
comprehension: number;
wordsPerMinute: number;
pauses: number;
errors: number;
minutesRead: number;
}
import type { WeeklyReadingMetrics } from '@/types/metrics';
interface MetricConfig {
key: string;
@ -44,11 +33,11 @@ const TIME_FILTERS: TimeFilterOption[] = [
];
interface MetricsChartProps {
data: WeeklyMetrics[];
data: WeeklyReadingMetrics[];
className?: string;
}
export function MetricsChart({ data, className = '' }: MetricsChartProps) {
export function MetricsChart({ data = [], className = '' }: MetricsChartProps) {
const [visibleMetrics, setVisibleMetrics] = React.useState<Set<string>>(
new Set(METRICS_CONFIG.map(metric => metric.key))
);
@ -66,7 +55,9 @@ export function MetricsChart({ data, className = '' }: MetricsChartProps) {
});
};
const filterDataByTime = (data: WeeklyMetrics[]): WeeklyMetrics[] => {
const filterDataByTime = (data: WeeklyReadingMetrics[]): WeeklyReadingMetrics[] => {
if (!data || !Array.isArray(data)) return [];
if (timeFilter === 'all') return data;
const months = TIME_FILTERS.find(f => f.value === timeFilter)?.months || 12;
@ -74,21 +65,23 @@ export function MetricsChart({ data, className = '' }: MetricsChartProps) {
cutoffDate.setMonth(cutoffDate.getMonth() - months);
return data.filter(item => {
if (!item?.week) return false;
const [year, week] = item.week.split('-W').map(Number);
if (!year || !week) return false;
const itemDate = new Date(year, 0, 1 + (week - 1) * 7);
return itemDate >= cutoffDate;
});
};
const filteredData = filterDataByTime(data);
const filteredData = React.useMemo(() => filterDataByTime(data), [data, timeFilter]);
return (
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-8 ${className}`}>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-gray-900">Evolução das Métricas por Semana</h2>
<p className="text-sm text-gray-500">Acompanhe seu progresso ao longo do tempo</p>
<h2 className="text-xl font-semibold text-gray-900">Evolução da Leitura por Semana</h2>
<p className="text-sm text-gray-500">Acompanhe seu progresso na leitura ao longo do tempo</p>
</div>
<div className="flex items-center gap-4">
{/* Filtro de Período */}
@ -191,9 +184,7 @@ export function MetricsChart({ data, className = '' }: MetricsChartProps) {
accuracy: 'Precisão',
comprehension: 'Compreensão',
wordsPerMinute: 'Palavras/Min',
pauses: 'Pausas',
errors: 'Erros',
minutesRead: 'Minutos Lidos'
minutesRead: 'Minutos Lendo'
};
return [value, metricNames[name] || name];
}}
@ -233,7 +224,7 @@ export function MetricsChart({ data, className = '' }: MetricsChartProps) {
<Bar
yAxisId="right"
dataKey="minutesRead"
name="Minutos Lidos"
name="Minutos Lendo"
fill="url(#barGradient)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}

View File

@ -0,0 +1,241 @@
import React from 'react';
import { Calendar, HelpCircle } from 'lucide-react';
import { Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Bar, ComposedChart } from 'recharts';
import type { WeeklyWritingMetrics } from '@/types/metrics';
interface MetricConfig {
key: string;
name: string;
color: string;
}
type TimeFilter = '3m' | '6m' | '12m' | 'all';
interface TimeFilterOption {
value: TimeFilter;
label: string;
months: number | null;
}
const METRICS_CONFIG: MetricConfig[] = [
{ key: 'score', name: 'Nota Geral', color: '#6366f1' },
{ key: 'adequacy', name: 'Adequação', color: '#f43f5e' },
{ key: 'coherence', name: 'Coerência', color: '#0ea5e9' },
{ key: 'cohesion', name: 'Coesão', color: '#10b981' },
{ key: 'vocabulary', name: 'Vocabulário', color: '#8b5cf6' },
{ key: 'grammar', name: 'Gramática', color: '#f59e0b' }
];
const TIME_FILTERS: TimeFilterOption[] = [
{ value: '3m', label: '3 meses', months: 3 },
{ value: '6m', label: '6 meses', months: 6 },
{ value: '12m', label: '12 meses', months: 12 },
{ value: 'all', label: 'Todo período', months: null },
];
interface WritingMetricsChartProps {
data: WeeklyWritingMetrics[];
className?: string;
}
export function WritingMetricsChart({ data = [], className = '' }: WritingMetricsChartProps) {
const [visibleMetrics, setVisibleMetrics] = React.useState<Set<string>>(
new Set(METRICS_CONFIG.map(metric => metric.key))
);
const [timeFilter, setTimeFilter] = React.useState<TimeFilter>('12m');
const toggleMetric = (metricKey: string) => {
setVisibleMetrics(prev => {
const newSet = new Set(prev);
if (newSet.has(metricKey)) {
newSet.delete(metricKey);
} else {
newSet.add(metricKey);
}
return newSet;
});
};
const filterDataByTime = (data: WeeklyWritingMetrics[]): WeeklyWritingMetrics[] => {
if (!data || !Array.isArray(data)) return [];
if (timeFilter === 'all') return data;
const months = TIME_FILTERS.find(f => f.value === timeFilter)?.months || 12;
const cutoffDate = new Date();
cutoffDate.setMonth(cutoffDate.getMonth() - months);
return data.filter(item => {
if (!item?.week) return false;
const [year, week] = item.week.split('-W').map(Number);
if (!year || !week) return false;
const itemDate = new Date(year, 0, 1 + (week - 1) * 7);
return itemDate >= cutoffDate;
});
};
const filteredData = React.useMemo(() => filterDataByTime(data), [data, timeFilter]);
return (
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 p-8 ${className}`}>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h2 className="text-xl font-semibold text-gray-900">Evolução da Escrita por Semana</h2>
<p className="text-sm text-gray-500">Acompanhe seu progresso na escrita ao longo do tempo</p>
</div>
<div className="flex items-center gap-4">
{/* Filtro de Período */}
<div className="flex items-center gap-2 bg-gray-50 p-1 rounded-lg">
<Calendar className="h-4 w-4 text-gray-500" />
{TIME_FILTERS.map(filter => (
<button
key={filter.value}
onClick={() => setTimeFilter(filter.value)}
className={`
px-3 py-1 rounded-md text-sm font-medium transition-all duration-200
${timeFilter === filter.value
? 'bg-white text-purple-600 shadow-sm'
: 'text-gray-600 hover:bg-gray-100'
}
`}
>
{filter.label}
</button>
))}
</div>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Gráfico mostrando a evolução das suas métricas de escrita ao longo das semanas"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
</div>
{/* Pill Buttons */}
<div className="flex flex-wrap gap-2 p-1">
{METRICS_CONFIG.map(metric => (
<button
key={metric.key}
onClick={() => toggleMetric(metric.key)}
className={`
px-4 py-2 rounded-full text-sm font-medium transition-all duration-200 ease-in-out
${visibleMetrics.has(metric.key)
? 'shadow-md transform -translate-y-px'
: 'bg-gray-50 text-gray-500 hover:bg-gray-100'
}
focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-opacity-50
`}
style={{
backgroundColor: visibleMetrics.has(metric.key) ? metric.color : undefined,
color: visibleMetrics.has(metric.key) ? 'white' : undefined,
boxShadow: visibleMetrics.has(metric.key) ? '0 2px 4px rgba(0,0,0,0.1)' : undefined
}}
>
<span className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${visibleMetrics.has(metric.key) ? 'bg-white' : 'bg-gray-400'}`}></span>
{metric.name}
</span>
</button>
))}
</div>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={filteredData} margin={{ top: 20, right: 30, left: 20, bottom: 20 }}>
<defs>
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.3} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="#f0f0f0"
/>
<XAxis
dataKey="week"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dy={10}
/>
<YAxis
yAxisId="left"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dx={-10}
/>
<YAxis
yAxisId="right"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fontSize: 12, fill: '#6b7280' }}
dx={10}
/>
<Tooltip
formatter={(value: number, name: string) => {
const metricNames: { [key: string]: string } = {
score: 'Nota Geral',
adequacy: 'Adequação',
coherence: 'Coerência',
cohesion: 'Coesão',
vocabulary: 'Vocabulário',
grammar: 'Gramática',
minutesWriting: 'Minutos Escrevendo'
};
return [value, metricNames[name] || name];
}}
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.98)',
border: 'none',
borderRadius: '8px',
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
padding: '12px'
}}
isAnimationActive={false}
/>
<Legend
verticalAlign="top"
align="right"
iconType="circle"
wrapperStyle={{
paddingBottom: '20px'
}}
/>
{METRICS_CONFIG.map(metric => (
visibleMetrics.has(metric.key) && (
<Line
key={metric.key}
yAxisId="left"
type="monotone"
dataKey={metric.key}
stroke={metric.color}
name={metric.name}
strokeWidth={2.5}
dot={{ strokeWidth: 2, r: 4, fill: 'white' }}
activeDot={{ r: 6, strokeWidth: 2 }}
isAnimationActive={false}
/>
)
))}
<Bar
yAxisId="right"
dataKey="minutesWriting"
name="Minutos Escrevendo"
fill="url(#barGradient)"
radius={[4, 4, 0, 0]}
isAnimationActive={false}
maxBarSize={50}
/>
</ComposedChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,143 @@
import React from 'react';
import { BookOpen, Clock, TrendingUp, Award, Target, Brain, Gauge, Sparkles, Puzzle, Pencil, HelpCircle } from 'lucide-react';
import { MetricCard } from './MetricCard';
import type { WritingMetrics } from '@/types/metrics';
interface WritingMetricsSectionProps {
data: WritingMetrics;
className?: string;
}
const MAIN_METRICS = [
{
key: 'totalEssays',
title: 'Total de Redações',
getValue: (data: WritingMetrics) => data.totalEssays,
icon: BookOpen,
iconColor: 'text-purple-600',
iconBgColor: 'bg-purple-100'
},
{
key: 'averageScore',
title: 'Nota Média',
getValue: (data: WritingMetrics) => `${data.averageScore}%`,
icon: TrendingUp,
iconColor: 'text-green-600',
iconBgColor: 'bg-green-100'
},
{
key: 'totalEssaysTime',
title: 'Tempo de Escrita',
getValue: (data: WritingMetrics) => `${data.totalEssaysTime}min`,
icon: Clock,
iconColor: 'text-blue-600',
iconBgColor: 'bg-blue-100'
},
{
key: 'currentWritingLevel',
title: 'Nível de Escrita',
getValue: (data: WritingMetrics) => data.currentWritingLevel,
icon: Award,
iconColor: 'text-yellow-600',
iconBgColor: 'bg-yellow-100'
}
];
const DETAILED_METRICS = [
{
key: 'averageAdequacy',
title: 'Adequação ao Tema',
getValue: (data: WritingMetrics) => `${data.averageAdequacy}%`,
icon: Target,
iconColor: 'text-indigo-600',
iconBgColor: 'bg-indigo-100',
tooltip: 'Avalia o quanto sua redação está alinhada com o tema e gênero propostos'
},
{
key: 'averageCoherence',
title: 'Coerência',
getValue: (data: WritingMetrics) => `${data.averageCoherence}%`,
icon: Brain,
iconColor: 'text-pink-600',
iconBgColor: 'bg-pink-100',
tooltip: 'Indica a clareza e lógica no desenvolvimento das ideias do texto'
},
{
key: 'averageCohesion',
title: 'Coesão',
getValue: (data: WritingMetrics) => `${data.averageCohesion}%`,
icon: Puzzle,
iconColor: 'text-orange-600',
iconBgColor: 'bg-orange-100',
tooltip: 'Avalia o uso adequado de conectivos e elementos de ligação entre as partes do texto'
},
{
key: 'averageVocabulary',
title: 'Vocabulário',
getValue: (data: WritingMetrics) => `${data.averageVocabulary}%`,
icon: Sparkles,
iconColor: 'text-cyan-600',
iconBgColor: 'bg-cyan-100',
tooltip: 'Analisa a riqueza e adequação do vocabulário utilizado'
},
{
key: 'averageGrammar',
title: 'Gramática',
getValue: (data: WritingMetrics) => `${data.averageGrammar}%`,
icon: Pencil,
iconColor: 'text-amber-600',
iconBgColor: 'bg-amber-100',
tooltip: 'Avalia o uso correto das regras gramaticais e ortográficas'
}
];
export function WritingMetricsSection({ data, className = '' }: WritingMetricsSectionProps) {
return (
<div className={className}>
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Métricas de Escrita</h2>
{/* Métricas Principais */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{MAIN_METRICS.map(metric => (
<MetricCard
key={metric.key}
title={metric.title}
value={metric.getValue(data)}
icon={metric.icon}
iconColor={metric.iconColor}
iconBgColor={metric.iconBgColor}
/>
))}
</div>
{/* Métricas Detalhadas */}
<div>
<div className="flex items-center gap-2 mb-4">
<h3 className="text-lg font-semibold text-gray-900">Métricas Detalhadas de Escrita</h3>
<div
className="text-gray-400 hover:text-gray-600 cursor-help transition-colors"
title="Estas métricas são calculadas com base em todas as suas redações, fornecendo uma visão detalhada do seu progresso na escrita"
>
<HelpCircle className="h-4 w-4" />
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{DETAILED_METRICS.map(metric => (
<MetricCard
key={metric.key}
title={metric.title}
value={metric.getValue(data)}
icon={metric.icon}
iconColor={metric.iconColor}
iconBgColor={metric.iconBgColor}
tooltip={metric.tooltip}
/>
))}
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,146 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/50",
"transition-all duration-200",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4",
"border bg-white p-6 shadow-lg rounded-lg",
"transition-all duration-300 ease-in-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -7,11 +7,41 @@ import { EVENT_CATEGORIES } from '../../constants/analytics';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
as?: 'button' | 'span';
trackingId: string;
variant?: 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'link';
variant?: 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'link' | 'destructive';
size?: 'sm' | 'md' | 'lg';
trackingProperties?: ButtonTrackingOptions;
}
export function buttonVariants({
variant = 'default',
size = 'md',
className = '',
}: {
variant?: ButtonProps['variant'];
size?: ButtonProps['size'];
className?: string;
} = {}) {
return cn(
'inline-flex items-center justify-center px-4 py-2',
'text-sm font-medium',
'rounded-md shadow-sm',
'transition-colors duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
{
'text-white bg-purple-600 hover:bg-purple-700': variant === 'primary' || variant === 'default',
'text-gray-700 bg-white border border-gray-300 hover:bg-gray-50': variant === 'secondary',
'text-purple-600 bg-transparent hover:bg-purple-50': variant === 'ghost',
'text-purple-600 bg-transparent hover:underline': variant === 'link',
'text-purple-600 border border-purple-600 hover:bg-purple-50': variant === 'outline',
'text-white bg-red-600 hover:bg-red-700': variant === 'destructive',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
},
className
);
}
export function Button({
as: Component = 'button',
children,
@ -41,29 +71,10 @@ export function Button({
onClick?.(event);
};
const baseStyles = cn(
'inline-flex items-center justify-center px-4 py-2',
'text-sm font-medium',
'rounded-md shadow-sm',
'transition-colors duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
{
'text-white bg-purple-600 hover:bg-purple-700': variant === 'primary' || variant === 'default',
'text-gray-700 bg-white border border-gray-300 hover:bg-gray-50': variant === 'secondary',
'text-purple-600 bg-transparent hover:bg-purple-50': variant === 'ghost',
'text-purple-600 bg-transparent hover:underline': variant === 'link',
'text-purple-600 border border-purple-600 hover:bg-purple-50': variant === 'outline',
'px-3 py-1.5 text-sm': size === 'sm',
'px-4 py-2 text-base': size === 'md',
'px-6 py-3 text-lg': size === 'lg',
},
className
);
return (
<Component
type={Component === 'button' ? type : undefined}
className={baseStyles}
className={buttonVariants({ variant, size, className })}
onClick={handleClick}
disabled={disabled}
{...props}

View File

@ -0,0 +1,251 @@
import { useEditor, EditorContent, Editor as TiptapEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import CharacterCount from '@tiptap/extension-character-count'
import Highlight from '@tiptap/extension-highlight'
import TextAlign from '@tiptap/extension-text-align'
import Underline from '@tiptap/extension-underline'
import TextStyle from '@tiptap/extension-text-style'
import Color from '@tiptap/extension-color'
import { useEffect } from 'react'
import { cn } from '@/lib/utils'
import { Button } from './button'
import {
Bold,
Italic,
Underline as UnderlineIcon,
AlignLeft,
AlignCenter,
AlignRight,
Highlighter,
Strikethrough,
Code,
List,
ListOrdered,
Quote,
} from 'lucide-react'
interface EditorProps {
content: string
onChange: (content: string) => void
placeholder?: string
className?: string
minHeight?: string
readOnly?: boolean
}
interface MenuBarProps {
editor: TiptapEditor | null
}
function MenuBar({ editor }: MenuBarProps) {
if (!editor) {
return null
}
return (
<div className="border-b border-input bg-transparent p-1">
<div className="flex flex-wrap gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBold().run()}
className={cn(editor.isActive('bold') && 'bg-muted')}
aria-label="Negrito"
trackingId="editor-bold-button"
>
<Bold className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleItalic().run()}
className={cn(editor.isActive('italic') && 'bg-muted')}
aria-label="Itálico"
trackingId="editor-italic-button"
>
<Italic className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleUnderline().run()}
className={cn(editor.isActive('underline') && 'bg-muted')}
aria-label="Sublinhado"
trackingId="editor-underline-button"
>
<UnderlineIcon className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleHighlight().run()}
className={cn(editor.isActive('highlight') && 'bg-muted')}
aria-label="Destacar"
trackingId="editor-highlight-button"
>
<Highlighter className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleStrike().run()}
className={cn(editor.isActive('strike') && 'bg-muted')}
aria-label="Tachado"
trackingId="editor-strike-button"
>
<Strikethrough className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleCode().run()}
className={cn(editor.isActive('code') && 'bg-muted')}
aria-label="Código"
trackingId="editor-code-button"
>
<Code className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBulletList().run()}
className={cn(editor.isActive('bulletList') && 'bg-muted')}
aria-label="Lista com marcadores"
trackingId="editor-bullet-list-button"
>
<List className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleOrderedList().run()}
className={cn(editor.isActive('orderedList') && 'bg-muted')}
aria-label="Lista numerada"
trackingId="editor-ordered-list-button"
>
<ListOrdered className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().toggleBlockquote().run()}
className={cn(editor.isActive('blockquote') && 'bg-muted')}
aria-label="Citação"
trackingId="editor-blockquote-button"
>
<Quote className="h-4 w-4" />
</Button>
<div className="mx-2 w-[1px] bg-border" />
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('left').run()}
className={cn(editor.isActive({ textAlign: 'left' }) && 'bg-muted')}
aria-label="Alinhar à esquerda"
trackingId="editor-align-left-button"
>
<AlignLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('center').run()}
className={cn(editor.isActive({ textAlign: 'center' }) && 'bg-muted')}
aria-label="Centralizar"
trackingId="editor-align-center-button"
>
<AlignCenter className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => editor.chain().focus().setTextAlign('right').run()}
className={cn(editor.isActive({ textAlign: 'right' }) && 'bg-muted')}
aria-label="Alinhar à direita"
trackingId="editor-align-right-button"
>
<AlignRight className="h-4 w-4" />
</Button>
</div>
</div>
)
}
export function Editor({
content,
onChange,
placeholder = 'Comece a escrever...',
className,
minHeight = '500px',
readOnly = false,
}: EditorProps) {
const editor = useEditor({
extensions: [
StarterKit.configure({
heading: false,
codeBlock: false,
horizontalRule: false,
}),
Placeholder.configure({
placeholder,
emptyEditorClass: 'is-editor-empty',
}),
CharacterCount,
Highlight.configure({
multicolor: true,
}),
TextAlign.configure({
types: ['paragraph'],
}),
Underline,
TextStyle,
Color,
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML())
},
editable: !readOnly,
})
useEffect(() => {
if (editor && content !== editor.getHTML()) {
editor.commands.setContent(content)
}
}, [content, editor])
return (
<div
className={cn(
'rounded-md border border-input bg-transparent',
'focus-within:outline-none focus-within:ring-2',
'focus-within:ring-ring focus-within:ring-offset-2',
'transition-all duration-200',
className
)}
>
{!readOnly && <MenuBar editor={editor} />}
<div
className={cn(
'prose prose-purple max-w-none px-4 py-2',
'[&_.is-editor-empty]:before:text-muted-foreground',
'[&_.is-editor-empty]:before:content-[attr(data-placeholder)]',
'[&_.is-editor-empty]:before:float-left',
'[&_.is-editor-empty]:before:h-0',
'[&_.is-editor-empty]:before:pointer-events-none',
'transition-all duration-200',
readOnly && 'prose-sm'
)}
style={{ minHeight }}
>
<EditorContent editor={editor} />
</div>
{!readOnly && editor && (
<div className="border-t border-input px-4 py-2 text-sm text-muted-foreground">
{editor.storage.characterCount.words()} palavras
</div>
)}
</div>
)
}

View File

@ -12,13 +12,13 @@ const Progress = React.forwardRef<
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
"relative h-4 w-full overflow-hidden rounded-full bg-gray-200",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
className="h-full w-full flex-1 bg-purple-600 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>

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

@ -8,13 +8,7 @@ if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Variáveis de ambiente do Supabase não configuradas')
}
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: true
}
})
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
export const generateStoryFunction = async (prompt: StoryPrompt) => {
const { data: { session } } = await supabase.auth.getSession()

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

@ -5,19 +5,15 @@ import { supabase } from '../../lib/supabase';
import type { Story, Student } from '../../types/database';
import { MetricsChart } from '@/components/dashboard/MetricsChart';
import { DashboardMetrics } from '@/components/dashboard/DashboardMetrics';
interface DashboardMetrics {
totalStories: number;
averageReadingFluency: number;
totalReadingTime: number;
currentLevel: number;
averagePronunciation: number;
averageAccuracy: number;
averageComprehension: number;
averageWordsPerMinute: number;
averagePauses: number;
averageErrors: number;
}
import { WritingMetricsSection } from '@/components/dashboard/WritingMetricsSection';
import { WritingMetricsChart } from '@/components/dashboard/WritingMetricsChart';
import { useMetricsStore } from '@/stores/metricsStore';
import type {
DashboardMetrics as DashboardMetricsType,
DashboardWeeklyMetrics,
WeeklyReadingMetrics,
WeeklyWritingMetrics
} from '@/types/metrics';
interface WeeklyMetrics {
week: string;
@ -54,25 +50,64 @@ interface WeeklyData {
minutesRead: number;
}
interface EssayAnalysis {
id: string;
created_at: string;
overall_score: number;
suggestions: string;
essay_analysis_scores: Array<{
adequacy: number;
coherence: number;
cohesion: number;
vocabulary: number;
grammar: number;
}>;
essay_analysis_feedback: Array<{
structure_feedback: string;
content_feedback: string;
language_feedback: string;
}>;
}
interface ProcessedEssayAnalysis {
id: string;
created_at: string;
overall_score: number;
essay_id: string;
scores: {
adequacy: number;
coherence: number;
cohesion: number;
vocabulary: number;
grammar: number;
};
feedback: {
structure_feedback: string;
content_feedback: string;
language_feedback: string;
};
}
export function StudentDashboardPage() {
const navigate = useNavigate();
const [student, setStudent] = React.useState<Student | null>(null);
const [metrics, setMetrics] = React.useState<DashboardMetrics>({
totalStories: 0,
averageReadingFluency: 0,
totalReadingTime: 0,
currentLevel: 1,
averagePronunciation: 0,
averageAccuracy: 0,
averageComprehension: 0,
averageWordsPerMinute: 0,
averagePauses: 0,
averageErrors: 0
});
const [weeklyMetrics, setWeeklyMetrics] = React.useState<WeeklyMetrics[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [recentStories, setRecentStories] = React.useState<Story[]>([]);
const {
metrics,
weeklyMetrics,
loading,
error,
setMetrics,
setWeeklyMetrics,
updateReadingMetrics,
updateWritingMetrics,
updateWeeklyReadingMetrics,
updateWeeklyWritingMetrics,
setLoading,
setError,
resetMetrics
} = useMetricsStore();
const processWeeklyMetrics = (recordings: Recording[]) => {
const weeklyData = recordings.reduce((acc: { [key: string]: WeeklyData }, recording) => {
@ -121,9 +156,54 @@ export function StudentDashboardPage() {
.sort((a, b) => a.week.localeCompare(b.week));
};
const processWeeklyWritingMetrics = (analyses: ProcessedEssayAnalysis[]) => {
const weeklyData = analyses.reduce((acc: { [key: string]: any }, analysis) => {
const date = new Date(analysis.created_at);
const week = `${date.getFullYear()}-W${Math.ceil((date.getDate() + date.getDay()) / 7)}`;
if (!acc[week]) {
acc[week] = {
count: 0,
score: 0,
adequacy: 0,
coherence: 0,
cohesion: 0,
vocabulary: 0,
grammar: 0,
minutesWriting: 0
};
}
acc[week].count += 1;
acc[week].score += analysis.overall_score;
acc[week].adequacy += analysis.scores.adequacy;
acc[week].coherence += analysis.scores.coherence;
acc[week].cohesion += analysis.scores.cohesion;
acc[week].vocabulary += analysis.scores.vocabulary;
acc[week].grammar += analysis.scores.grammar;
acc[week].minutesWriting += 30; // Tempo médio estimado por redação
return acc;
}, {});
return Object.entries(weeklyData)
.map(([week, data]: [string, any]) => ({
week,
score: Math.round(data.score / data.count),
adequacy: Math.round(data.adequacy / data.count),
coherence: Math.round(data.coherence / data.count),
cohesion: Math.round(data.cohesion / data.count),
vocabulary: Math.round(data.vocabulary / data.count),
grammar: Math.round(data.grammar / data.count),
minutesWriting: data.minutesWriting
}))
.sort((a, b) => a.week.localeCompare(b.week));
};
React.useEffect(() => {
const fetchDashboardData = async () => {
try {
setLoading(true);
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
@ -164,7 +244,8 @@ export function StudentDashboardPage() {
// Processar métricas semanais
const weeklyData = processWeeklyMetrics(recordings);
setWeeklyMetrics(weeklyData);
// Atualizar métricas semanais de leitura
updateWeeklyReadingMetrics(weeklyData);
// Buscar histórias recentes com a capa definida
const { data: stories, error: storiesError } = await supabase
@ -223,8 +304,8 @@ export function StudentDashboardPage() {
errors: 0
});
// Calcular médias
setMetrics({
// Atualizar métricas de leitura
updateReadingMetrics({
totalStories: allStoriesData.length,
averageReadingFluency: Math.round(metricsSum.fluency / totalRecordings),
totalReadingTime: recordings.length * 2,
@ -238,6 +319,125 @@ export function StudentDashboardPage() {
});
}
// Buscar todas as redações do aluno
const { data: essays, error: essaysError } = await supabase
.from('student_essays')
.select(`
id,
created_at,
status,
essay_analyses(
id,
overall_score,
suggestions,
created_at,
essay_analysis_scores(
adequacy,
coherence,
cohesion,
vocabulary,
grammar
),
essay_analysis_feedback(
structure_feedback,
content_feedback,
language_feedback
)
)
`)
.eq('student_id', session.user.id)
.eq('status', 'analyzed')
.order('created_at', { ascending: true });
if (essaysError) throw essaysError;
console.log('Redações carregadas:', essays);
// Processar métricas semanais de escrita
const analyses = essays?.flatMap(essay =>
essay.essay_analyses?.map(analysis => {
console.log('Análise individual:', analysis);
return {
id: analysis.id,
created_at: analysis.created_at,
overall_score: analysis.overall_score || 0,
essay_id: essay.id,
scores: {
adequacy: analysis.essay_analysis_scores?.[0]?.adequacy || 0,
coherence: analysis.essay_analysis_scores?.[0]?.coherence || 0,
cohesion: analysis.essay_analysis_scores?.[0]?.cohesion || 0,
vocabulary: analysis.essay_analysis_scores?.[0]?.vocabulary || 0,
grammar: analysis.essay_analysis_scores?.[0]?.grammar || 0
},
feedback: {
structure_feedback: analysis.essay_analysis_feedback?.[0]?.structure_feedback || '',
content_feedback: analysis.essay_analysis_feedback?.[0]?.content_feedback || '',
language_feedback: analysis.essay_analysis_feedback?.[0]?.language_feedback || ''
}
};
})
).filter(Boolean) || [];
console.log('Análises processadas:', analyses);
// Calcular métricas gerais de escrita
if (analyses && analyses.length > 0) {
const totalAnalyses = analyses.length;
const metricsSum = analyses.reduce((acc, analysis) => ({
score: acc.score + (analysis.overall_score || 0),
adequacy: acc.adequacy + (analysis.scores?.adequacy || 0),
coherence: acc.coherence + (analysis.scores?.coherence || 0),
cohesion: acc.cohesion + (analysis.scores?.cohesion || 0),
vocabulary: acc.vocabulary + (analysis.scores?.vocabulary || 0),
grammar: acc.grammar + (analysis.scores?.grammar || 0)
}), {
score: 0,
adequacy: 0,
coherence: 0,
cohesion: 0,
vocabulary: 0,
grammar: 0
});
console.log('Soma das métricas:', metricsSum);
console.log('Total de análises:', totalAnalyses);
// Atualizar métricas de escrita
const writingMetrics = {
totalEssays: essays?.length || 0,
averageScore: Math.round(metricsSum.score / totalAnalyses),
totalEssaysTime: totalAnalyses * 30,
currentWritingLevel: Math.ceil(metricsSum.score / (totalAnalyses * 20)),
averageAdequacy: Math.round(metricsSum.adequacy / totalAnalyses),
averageCoherence: Math.round(metricsSum.coherence / totalAnalyses),
averageCohesion: Math.round(metricsSum.cohesion / totalAnalyses),
averageVocabulary: Math.round(metricsSum.vocabulary / totalAnalyses),
averageGrammar: Math.round(metricsSum.grammar / totalAnalyses)
};
console.log('Métricas de escrita calculadas:', writingMetrics);
updateWritingMetrics(writingMetrics);
const weeklyWritingData = processWeeklyWritingMetrics(analyses);
console.log('Dados semanais de escrita:', weeklyWritingData);
updateWeeklyWritingMetrics(weeklyWritingData);
} else {
console.log('Nenhuma análise encontrada');
// Definir valores padrão quando não há análises
updateWritingMetrics({
totalEssays: 0,
averageScore: 0,
totalEssaysTime: 0,
currentWritingLevel: 1,
averageAdequacy: 0,
averageCoherence: 0,
averageCohesion: 0,
averageVocabulary: 0,
averageGrammar: 0
});
updateWeeklyWritingMetrics([]);
}
} catch (err) {
console.error('Erro ao carregar dashboard:', err);
setError('Não foi possível carregar seus dados');
@ -247,6 +447,11 @@ export function StudentDashboardPage() {
};
fetchDashboardData();
// Limpar métricas ao desmontar
return () => {
resetMetrics();
};
}, []);
if (loading) {
@ -317,11 +522,27 @@ export function StudentDashboardPage() {
</div>
</div>
{/* Métricas */}
<DashboardMetrics data={metrics} />
{/* Seção de Métricas de Leitura */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Métricas de Leitura</h2>
{/* Métricas de Leitura */}
<DashboardMetrics data={metrics.reading} className="mb-8" />
{/* Gráfico de Evolução */}
<MetricsChart data={weeklyMetrics} className="mb-8" />
{/* Gráfico de Evolução da Leitura */}
<MetricsChart data={weeklyMetrics.reading} className="mb-8" />
</div>
{/* Seção de Métricas de Escrita */}
<div className="mb-12">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Métricas de Escrita</h2>
{/* Métricas de Escrita */}
<WritingMetricsSection data={metrics.writing} className="mb-8" />
{/* Gráfico de Evolução da Escrita */}
<WritingMetricsChart data={weeklyMetrics.writing} className="mb-8" />
</div>
{/* Histórias Recentes */}
<div>

View File

@ -0,0 +1,310 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
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;
};
}
interface EssayAnalysisData {
id: string;
essay_id: string;
overall_score: number;
suggestions: string;
created_at: string;
feedback: Array<{
structure_feedback: string;
content_feedback: string;
language_feedback: string;
}>;
strengths: Array<{
strength: string;
}>;
improvements: Array<{
improvement: string;
}>;
scores: Array<{
adequacy: number;
coherence: number;
cohesion: number;
vocabulary: number;
grammar: number;
}>;
}
export function EssayAnalysis() {
const navigate = useNavigate();
const { id } = useParams();
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, error: analysisError } = await supabase
.from('essay_analyses')
.select(`
*,
feedback:essay_analysis_feedback(
structure_feedback,
content_feedback,
language_feedback
),
strengths:essay_analysis_strengths(
strength
),
improvements:essay_analysis_improvements(
improvement
),
scores:essay_analysis_scores(
adequacy,
coherence,
cohesion,
vocabulary,
grammar
)
`)
.eq('essay_id', id)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (analysisError) throw analysisError;
// Transformar os dados para o formato esperado
const analysisData = data as EssayAnalysisData;
const analysis: EssayAnalysis = {
...analysisData,
feedback: {
structure: analysisData.feedback[0]?.structure_feedback || '',
content: analysisData.feedback[0]?.content_feedback || '',
language: analysisData.feedback[0]?.language_feedback || ''
},
strengths: analysisData.strengths?.map((s: { strength: string }) => s.strength) || [],
improvements: analysisData.improvements?.map((i: { improvement: string }) => i.improvement) || [],
criteria_scores: {
adequacy: analysisData.scores[0]?.adequacy || 0,
coherence: analysisData.scores[0]?.coherence || 0,
cohesion: analysisData.scores[0]?.cohesion || 0,
vocabulary: analysisData.scores[0]?.vocabulary || 0,
grammar: analysisData.scores[0]?.grammar || 0
}
};
setAnalysis(analysis);
} 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={() => navigate('/aluno/redacoes')}
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
trackingId="essay-analysis-back-to-list-button"
>
<ArrowLeft className="h-5 w-5" />
Voltar para redações
</Button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
{/* Título e Tipo */}
<div className="border-b border-gray-200 pb-6">
<h1 className="text-2xl font-bold text-gray-900">{essay.title}</h1>
<p className="text-gray-500 mt-1">
{essay.essay_type.title} {essay.essay_genre.title}
</p>
</div>
{/* Grid de Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Pontuação Geral */}
<Card className="bg-purple-50 border-purple-100">
<CardContent className="pt-6">
<div className="flex items-center justify-center">
<div className="text-6xl font-bold text-purple-600">{analysis.overall_score}</div>
<div className="text-2xl text-purple-600 ml-2">/100</div>
</div>
<p className="text-center text-gray-600 mt-2">Pontuação Geral</p>
</CardContent>
</Card>
{/* Pontos Fortes */}
<Card className="bg-green-50 border-green-100">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-green-600">
<CheckCircle2 className="h-5 w-5" />
Pontos Fortes
</CardTitle>
</CardHeader>
<CardContent>
<ul className="list-disc list-inside space-y-2">
{analysis.strengths.map((strength, index) => (
<li key={index} className="text-green-600">{strength}</li>
))}
</ul>
</CardContent>
</Card>
{/* Pontos a Melhorar */}
<Card className="bg-orange-50 border-orange-100">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-orange-600">
<XCircle className="h-5 w-5" />
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-orange-600">{improvement}</li>
))}
</ul>
</CardContent>
</Card>
{/* Feedback Detalhado */}
<Card className="md:col-span-2 border-gray-200">
<CardHeader className="border-b border-gray-200">
<CardTitle>Feedback Detalhado</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-4">
<div>
<h4 className="font-semibold mb-2 text-gray-900">Estrutura</h4>
<p className="text-gray-600">{analysis.feedback.structure}</p>
</div>
<div>
<h4 className="font-semibold mb-2 text-gray-900">Conteúdo</h4>
<p className="text-gray-600">{analysis.feedback.content}</p>
</div>
<div>
<h4 className="font-semibold mb-2 text-gray-900">Linguagem</h4>
<p className="text-gray-600">{analysis.feedback.language}</p>
</div>
</CardContent>
</Card>
{/* Critérios de Avaliação */}
<Card className="border-gray-200">
<CardHeader className="border-b border-gray-200">
<CardTitle>Critérios de Avaliação</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-4">
<div>
<div className="flex justify-between mb-1">
<span className="text-gray-600">Adequação ao Gênero</span>
<span className="font-medium">{analysis.criteria_scores.adequacy}%</span>
</div>
<Progress value={analysis.criteria_scores.adequacy} className="h-2" />
</div>
<div>
<div className="flex justify-between mb-1">
<span className="text-gray-600">Coerência</span>
<span className="font-medium">{analysis.criteria_scores.coherence}%</span>
</div>
<Progress value={analysis.criteria_scores.coherence} className="h-2" />
</div>
<div>
<div className="flex justify-between mb-1">
<span className="text-gray-600">Coesão</span>
<span className="font-medium">{analysis.criteria_scores.cohesion}%</span>
</div>
<Progress value={analysis.criteria_scores.cohesion} className="h-2" />
</div>
<div>
<div className="flex justify-between mb-1">
<span className="text-gray-600">Vocabulário</span>
<span className="font-medium">{analysis.criteria_scores.vocabulary}%</span>
</div>
<Progress value={analysis.criteria_scores.vocabulary} className="h-2" />
</div>
<div>
<div className="flex justify-between mb-1">
<span className="text-gray-600">Gramática</span>
<span className="font-medium">{analysis.criteria_scores.grammar}%</span>
</div>
<Progress value={analysis.criteria_scores.grammar} className="h-2" />
</div>
</CardContent>
</Card>
{/* Sugestões */}
<Card className="md:col-span-3 bg-blue-50 border-blue-100">
<CardHeader>
<CardTitle className="text-blue-600">Sugestões para Melhoria</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-600 whitespace-pre-line">
{analysis.suggestions}
</p>
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,408 @@
import { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
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, Trash2, BarChart3 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { useSession } from '@/hooks/useSession';
import { useUppercasePreference } from '@/hooks/useUppercasePreference';
import { AdaptiveText, AdaptiveTitle, AdaptiveParagraph } from '@/components/ui/adaptive-text';
import { TextCaseToggle } from '@/components/ui/text-case-toggle';
import { cn } from '@/lib/utils';
import { Editor } from '@/components/ui/editor';
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 function EssayPage() {
const navigate = useNavigate();
const { id } = useParams();
const [essay, setEssay] = useState<Essay | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [wordCount, setWordCount] = useState(0);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const { session } = useSession();
const { isUpperCase, toggleUppercase, isLoading: isUppercaseLoading } = useUppercasePreference(session?.user?.id);
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(*),
content
`)
.eq('id', id)
.single();
if (error) throw error;
setEssay(data);
// Atualizar contagem de palavras
if (data?.content) {
const words = data.content.trim().split(/\s+/).length;
setWordCount(words);
}
} 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 {
setSaving(true);
// Primeiro salvar o conteúdo atual
const { error: saveError } = await supabase
.from('student_essays')
.update({
title: essay.title,
content: essay.content,
status: 'submitted'
})
.eq('id', essay.id);
if (saveError) throw saveError;
// 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
navigate(`/aluno/redacoes/${essay.id}/analise`);
} catch (error) {
console.error('Erro ao enviar para análise:', error);
} finally {
setSaving(false);
}
}
async function deleteEssay() {
if (!essay) return;
try {
const { error } = await supabase
.from('student_essays')
.delete()
.eq('id', essay.id);
if (error) throw error;
navigate('/aluno/redacoes');
} catch (error) {
console.error('Erro ao deletar redação:', 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 gap-4 mb-6">
<Button
variant="ghost"
onClick={() => navigate('/aluno/redacoes')}
className="text-gray-600 hover:text-gray-900"
trackingId="essay-back-to-list-button"
trackingProperties={{
action: 'back_to_essays_list',
page: 'essay_editor'
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
<AdaptiveText text="Voltar para redações" isUpperCase={isUpperCase} />
</Button>
<TextCaseToggle
isUpperCase={isUpperCase}
onToggle={toggleUppercase}
isLoading={isUppercaseLoading}
className="ml-auto"
/>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 space-y-6">
{loading ? (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-100 rounded w-1/3" />
<div className="h-4 bg-gray-100 rounded w-1/4" />
<div className="h-64 bg-gray-100 rounded mt-8" />
</div>
) : !essay ? (
<div className="text-center py-12">
<AdaptiveText
text="Redação não encontrada"
isUpperCase={isUpperCase}
className="text-gray-600"
/>
</div>
) : (
<>
<div className="flex items-center justify-between">
<div className="flex-1">
<Input
value={essay.title}
onChange={(e) => setEssay({ ...essay, title: e.target.value })}
className="text-2xl font-bold border-none focus:border-none bg-transparent px-0"
placeholder="Título da redação"
/>
<div className="flex items-center gap-2 text-sm text-gray-600 mt-1">
<AdaptiveText text={essay.essay_type?.title} isUpperCase={isUpperCase} />
<span></span>
<AdaptiveText text={essay.essay_genre?.title} isUpperCase={isUpperCase} />
<span></span>
<Badge variant={essay.status === 'draft' ? 'secondary' : essay.status === 'analyzed' ? 'success' : 'default'}>
<AdaptiveText
text={essay.status === 'draft' ? 'Rascunho' : essay.status === 'analyzed' ? 'Analisada' : 'Enviada'}
isUpperCase={isUpperCase}
/>
</Badge>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={saveEssay}
disabled={saving}
trackingId="essay-save-button"
trackingProperties={{
action: 'save_essay',
page: 'essay_editor',
status: essay.status
}}
>
<Save className="mr-2 h-4 w-4" />
<AdaptiveText
text={saving ? 'Salvando...' : 'Salvar'}
isUpperCase={isUpperCase}
/>
</Button>
{essay.status === 'draft' && (
<>
<Button
onClick={submitForAnalysis}
disabled={!isWithinWordLimit}
title={!isWithinWordLimit ? 'Número de palavras fora do limite' : ''}
trackingId="essay-submit-analysis-button"
trackingProperties={{
action: 'submit_for_analysis',
page: 'essay_editor',
word_count: wordCount,
within_limit: isWithinWordLimit
}}
>
<Send className="mr-2 h-4 w-4" />
<AdaptiveText text="Enviar para análise" isUpperCase={isUpperCase} />
</Button>
<Button
variant="destructive"
onClick={() => setShowDeleteDialog(true)}
trackingId="essay-delete-button"
trackingProperties={{
action: 'delete_essay',
page: 'essay_editor',
status: essay.status
}}
>
<Trash2 className="mr-2 h-4 w-4" />
<AdaptiveText text="Deletar" isUpperCase={isUpperCase} />
</Button>
</>
)}
{essay.status === 'analyzed' && (
<Button
variant="default"
onClick={() => navigate(`/aluno/redacoes/${essay.id}/analise`)}
className="bg-purple-600 hover:bg-purple-700 text-white"
trackingId="essay-view-analysis-button"
trackingProperties={{
action: 'view_analysis',
page: 'essay_editor'
}}
>
<BarChart3 className="mr-2 h-4 w-4" />
<AdaptiveText text="Ver Análise" isUpperCase={isUpperCase} />
</Button>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="md:col-span-3">
<Editor
content={essay.content || ''}
onChange={(newContent) => essay.status === 'draft' ? setEssay({ ...essay, content: newContent }) : null}
placeholder={essay.status === 'draft' ? "Escreva sua redação aqui..." : ""}
readOnly={essay.status !== 'draft'}
className={cn(
"min-h-[400px]",
essay.status !== 'draft' && "bg-gray-50"
)}
/>
<div className="mt-2 text-sm">
<span className={cn(
"font-medium",
isWithinWordLimit ? "text-gray-600" : "text-red-600"
)}>
{wordCount} palavras
</span>
{essay.status === 'draft' && !isWithinWordLimit && (
<span className="text-red-600">
{' '}(mínimo: {essay.essay_genre.requirements.min_words},
máximo: {essay.essay_genre.requirements.max_words})
</span>
)}
</div>
</div>
<Card className="h-fit">
<CardContent className="p-4">
<h3 className="font-semibold mb-4">
<AdaptiveText text="Requisitos do Gênero" isUpperCase={isUpperCase} />
</h3>
<div className="space-y-4">
<div className="bg-purple-50 p-4 rounded-lg border border-purple-100">
<div className="space-y-2 text-sm text-purple-800">
<p>
<AdaptiveText
text={`Mínimo: ${essay.essay_genre.requirements.min_words} palavras`}
isUpperCase={isUpperCase}
/>
</p>
<p>
<AdaptiveText
text={`Máximo: ${essay.essay_genre.requirements.max_words} palavras`}
isUpperCase={isUpperCase}
/>
</p>
<p className="mt-2 font-medium">
<AdaptiveText text="Elementos necessários:" isUpperCase={isUpperCase} />
</p>
<ul className="list-disc list-inside space-y-1">
{essay.essay_genre.requirements.required_elements.map((element, index) => (
<li key={index}>
<AdaptiveText text={element} isUpperCase={isUpperCase} />
</li>
))}
</ul>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</>
)}
</div>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<AdaptiveText text="Você tem certeza?" isUpperCase={isUpperCase} />
</AlertDialogTitle>
<AlertDialogDescription>
<AdaptiveText
text="Esta ação não pode ser desfeita. Isso excluirá permanentemente sua redação."
isUpperCase={isUpperCase}
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<AdaptiveText text="Cancelar" isUpperCase={isUpperCase} />
</AlertDialogCancel>
<AlertDialogAction onClick={deleteEssay} className="bg-red-600 hover:bg-red-700">
<AdaptiveText text="Deletar" isUpperCase={isUpperCase} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -0,0 +1,269 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '@/lib/supabase';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Sparkles } from 'lucide-react';
import { useSession } from '@/hooks/useSession';
import { useUppercasePreference } from '@/hooks/useUppercasePreference';
import { AdaptiveText, AdaptiveTitle, AdaptiveParagraph } from '@/components/ui/adaptive-text';
import { TextCaseToggle } from '@/components/ui/text-case-toggle';
import { cn } from '@/lib/utils';
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 function NewEssay() {
const navigate = useNavigate();
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);
const { session } = useSession();
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
useEffect(() => {
loadTypes();
}, []);
// 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: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Usuário não autenticado');
const { data, error } = await supabase
.from('student_essays')
.insert({
student_id: user.id,
type_id: selectedType!.id,
genre_id: genreId,
status: 'draft',
title: 'Nova Redação',
content: ''
})
.select()
.single();
if (error) throw error;
navigate(`/aluno/redacoes/${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={cn(
"cursor-pointer hover:shadow-lg transition-all duration-200",
"bg-white border border-gray-200",
selectedType?.id === type.id && "ring-2 ring-purple-600 bg-purple-50"
)}
onClick={() => {
setSelectedType(type);
loadGenres(type.id);
setStep('genre');
}}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="p-2 bg-purple-100 rounded-lg">
<span className="text-2xl">{type.icon}</span>
</div>
<AdaptiveText
text={type.title}
isUpperCase={isUpperCase}
className="font-bold"
/>
</CardTitle>
<CardDescription>
<AdaptiveText
text={type.description}
isUpperCase={isUpperCase}
className="text-gray-600"
/>
</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-all duration-200 bg-white border border-gray-200"
onClick={() => createEssay(genre.id)}
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="p-2 bg-purple-100 rounded-lg">
<span className="text-2xl">{genre.icon}</span>
</div>
<AdaptiveText
text={genre.title}
isUpperCase={isUpperCase}
className="font-bold"
/>
</CardTitle>
<CardDescription>
<AdaptiveText
text={genre.description}
isUpperCase={isUpperCase}
className="text-gray-600"
/>
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="bg-purple-50 p-4 rounded-lg border border-purple-100">
<h4 className="text-sm font-medium text-purple-900 mb-2">Requisitos</h4>
<div className="space-y-2 text-sm text-purple-800">
<p>Mínimo: {genre.requirements?.min_words || 0} palavras</p>
<p>Máximo: {genre.requirements?.max_words || 'Sem limite'} palavras</p>
{genre.requirements?.required_elements && genre.requirements.required_elements.length > 0 && (
<>
<p className="mt-2 font-medium">Elementos necessários:</p>
<ul className="list-disc list-inside space-y-1">
{genre.requirements.required_elements.map((element, index) => (
<li key={index}>
<AdaptiveText
text={element}
isUpperCase={isUpperCase}
/>
</li>
))}
</ul>
</>
)}
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="container mx-auto p-6">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex items-center gap-3 mb-8">
<div className="p-2 bg-purple-100 rounded-lg">
<Sparkles className="h-6 w-6 text-purple-600" />
</div>
<div>
<AdaptiveTitle
text="Nova Redação"
isUpperCase={isUpperCase}
className="text-2xl font-bold text-gray-900"
/>
<AdaptiveParagraph
text={step === 'type'
? "Selecione o tipo textual para começar"
: "Escolha o gênero textual da sua redação"}
isUpperCase={isUpperCase}
className="text-gray-600"
/>
</div>
<TextCaseToggle
isUpperCase={isUpperCase}
onToggle={toggleUppercase}
isLoading={isLoading}
className="ml-auto"
/>
</div>
{step === 'genre' && (
<Button
variant="ghost"
onClick={() => setStep('type')}
className="mb-6"
trackingId="essay-new-back-button"
trackingProperties={{
action: 'back_to_type_selection',
current_step: 'genre',
page: 'new_essay'
}}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Voltar para tipos textuais
</Button>
)}
{loading ? (
<div className="animate-pulse space-y-4">
<div className="h-48 bg-gray-100 rounded-lg" />
<div className="h-48 bg-gray-100 rounded-lg" />
<div className="h-48 bg-gray-100 rounded-lg" />
</div>
) : step === 'type' ? (
renderTypeSelection()
) : (
renderGenreSelection()
)}
</div>
</div>
);
}

View File

@ -0,0 +1,286 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '@/lib/supabase';
import { Button } from '@/components/ui/button';
import { Plus, Search, Filter, PenTool } from 'lucide-react';
import { useSession } from '@/hooks/useSession';
import { useUppercasePreference } from '@/hooks/useUppercasePreference';
import { AdaptiveText } from '@/components/ui/adaptive-text';
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;
};
}
type EssayStatus = 'all' | 'draft' | 'submitted' | 'analyzed';
type SortOption = 'recent' | 'oldest' | 'title';
export function EssaysPage() {
const navigate = useNavigate();
const [essays, setEssays] = useState<Essay[]>([]);
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(() => {
loadEssays();
}, [statusFilter, sortBy]);
async function loadEssays() {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
const query = supabase
.from('student_essays')
.select(`
*,
essay_type:essay_types(title),
essay_genre:essay_genres(title)
`)
.eq('student_id', session.user.id);
if (statusFilter !== 'all') {
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) {
console.error('Erro ao carregar redações:', error);
setError('Não foi possível carregar suas redações');
} finally {
setLoading(false);
}
}
const filteredEssays = essays.filter(essay =>
essay.title.toLowerCase().includes(searchTerm.toLowerCase())
);
function getStatusBadge(status: Essay['status']) {
const statusMap = {
draft: { label: 'Rascunho', variant: 'secondary' as const, classes: 'bg-yellow-100 text-yellow-800' },
submitted: { label: 'Enviada', variant: 'default' as const, classes: 'bg-blue-100 text-blue-800' },
analyzed: { label: 'Analisada', variant: 'success' as const, classes: 'bg-green-100 text-green-800' }
};
const { label, classes } = statusMap[status];
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 (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold text-gray-900">
<AdaptiveText text="Minhas Redações" isUpperCase={isUpperCase} />
</h1>
<Button
onClick={() => navigate('/aluno/redacoes/nova')}
className="flex items-center gap-2"
trackingId="essay-new-create-button"
trackingProperties={{
action: 'create_new_essay',
page: 'essays_list'
}}
>
<Plus className="h-5 w-5" />
<AdaptiveText text="Nova Redação" isUpperCase={isUpperCase} />
</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 redações..."
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" />
<AdaptiveText text="Filtros" isUpperCase={isUpperCase} />
</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>
</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'
}`}
>
<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>
{/* 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>
);
}

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 { NewEssay } from './pages/student-dashboard/essays/NewEssay';
import { EssayPage } from './pages/student-dashboard/essays/EssayPage';
import { EssayAnalysis } from './pages/student-dashboard/essays/EssayAnalysis';
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: <NewEssay />,
},
{
path: ':id',
element: <EssayPage />,
},
{
path: ':id/analise',
element: <EssayAnalysis />,
}
]
}
]
},

106
src/stores/metricsStore.ts Normal file
View File

@ -0,0 +1,106 @@
import { create } from 'zustand';
import type {
DashboardMetrics,
DashboardWeeklyMetrics,
ReadingMetrics,
WritingMetrics,
WeeklyReadingMetrics,
WeeklyWritingMetrics
} from '@/types/metrics';
interface MetricsState {
metrics: DashboardMetrics;
weeklyMetrics: DashboardWeeklyMetrics;
loading: boolean;
error: string | null;
setMetrics: (metrics: DashboardMetrics) => void;
setWeeklyMetrics: (weeklyMetrics: DashboardWeeklyMetrics) => void;
updateReadingMetrics: (readingMetrics: ReadingMetrics) => void;
updateWritingMetrics: (writingMetrics: WritingMetrics) => void;
updateWeeklyReadingMetrics: (weeklyReadingMetrics: WeeklyReadingMetrics[]) => void;
updateWeeklyWritingMetrics: (weeklyWritingMetrics: WeeklyWritingMetrics[]) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
resetMetrics: () => void;
}
const initialMetrics: DashboardMetrics = {
reading: {
totalStories: 0,
averageReadingFluency: 0,
totalReadingTime: 0,
currentLevel: 1,
averagePronunciation: 0,
averageAccuracy: 0,
averageComprehension: 0,
averageWordsPerMinute: 0,
averagePauses: 0,
averageErrors: 0
},
writing: {
totalEssays: 0,
averageScore: 0,
totalEssaysTime: 0,
currentWritingLevel: 1,
averageAdequacy: 0,
averageCoherence: 0,
averageCohesion: 0,
averageVocabulary: 0,
averageGrammar: 0
}
};
const initialWeeklyMetrics: DashboardWeeklyMetrics = {
reading: [],
writing: []
};
export const useMetricsStore = create<MetricsState>((set) => ({
metrics: initialMetrics,
weeklyMetrics: initialWeeklyMetrics,
loading: true,
error: null,
setMetrics: (metrics) => set({ metrics }),
setWeeklyMetrics: (weeklyMetrics) => set({ weeklyMetrics }),
updateReadingMetrics: (readingMetrics) => set((state) => ({
metrics: {
...state.metrics,
reading: readingMetrics
}
})),
updateWritingMetrics: (writingMetrics) => set((state) => ({
metrics: {
...state.metrics,
writing: writingMetrics
}
})),
updateWeeklyReadingMetrics: (weeklyReadingMetrics) => set((state) => ({
weeklyMetrics: {
...state.weeklyMetrics,
reading: weeklyReadingMetrics
}
})),
updateWeeklyWritingMetrics: (weeklyWritingMetrics) => set((state) => ({
weeklyMetrics: {
...state.weeklyMetrics,
writing: weeklyWritingMetrics
}
})),
setLoading: (loading) => set({ loading }),
setError: (error) => set({ error }),
resetMetrics: () => set({
metrics: initialMetrics,
weeklyMetrics: initialWeeklyMetrics,
loading: false,
error: null
})
}));

57
src/types/metrics.ts Normal file
View File

@ -0,0 +1,57 @@
export interface ReadingMetrics {
totalStories: number;
averageReadingFluency: number;
totalReadingTime: number;
currentLevel: number;
averagePronunciation: number;
averageAccuracy: number;
averageComprehension: number;
averageWordsPerMinute: number;
averagePauses: number;
averageErrors: number;
}
export interface WritingMetrics {
totalEssays: number;
averageScore: number;
totalEssaysTime: number;
currentWritingLevel: number;
averageAdequacy: number;
averageCoherence: number;
averageCohesion: number;
averageVocabulary: number;
averageGrammar: number;
}
export interface WeeklyReadingMetrics {
week: string;
fluency: number;
pronunciation: number;
accuracy: number;
comprehension: number;
wordsPerMinute: number;
pauses: number;
errors: number;
minutesRead: number;
}
export interface WeeklyWritingMetrics {
week: string;
score: number;
adequacy: number;
coherence: number;
cohesion: number;
vocabulary: number;
grammar: number;
minutesWriting: number;
}
export interface DashboardMetrics {
reading: ReadingMetrics;
writing: WritingMetrics;
}
export interface DashboardWeeklyMetrics {
reading: WeeklyReadingMetrics[];
writing: WeeklyWritingMetrics[];
}

View File

@ -88,6 +88,8 @@
| public | interests | item | UNIQUE |
| public | interests | student_id | FOREIGN KEY |
| public | languages | code | UNIQUE |
| public | essay_analysis_scores | id | PRIMARY KEY |
| public | essay_analysis_scores | analysis_id | FOREIGN KEY |
| supabase_migrations | schema_migrations | version | PRIMARY KEY |
| supabase_migrations | seed_files | path | PRIMARY KEY |
| public | phonics_categories | id | PRIMARY KEY |
@ -108,10 +110,19 @@
| public | story_characters | slug | UNIQUE |
| public | story_settings | id | PRIMARY KEY |
| public | story_settings | slug | UNIQUE |
| public | essay_types | id | PRIMARY KEY |
| public | essay_types | slug | UNIQUE |
| public | stories | theme_id | FOREIGN KEY |
| public | stories | subject_id | FOREIGN KEY |
| public | stories | character_id | FOREIGN KEY |
| public | stories | setting_id | FOREIGN KEY |
| public | essay_genres | id | PRIMARY KEY |
| public | essay_genres | slug | UNIQUE |
| public | essay_genres | type_id | FOREIGN KEY |
| public | student_essays | id | PRIMARY KEY |
| public | student_essays | student_id | FOREIGN KEY |
| public | student_essays | type_id | FOREIGN KEY |
| public | student_essays | genre_id | FOREIGN KEY |
| auth | identities | user_id | FOREIGN KEY |
| auth | refresh_tokens | token | UNIQUE |
| auth | sessions | id | PRIMARY KEY |
@ -150,12 +161,20 @@
| storage | s3_multipart_uploads_parts | bucket_id | FOREIGN KEY |
| realtime | schema_migrations | version | PRIMARY KEY |
| realtime | subscription | id | PRIMARY KEY |
| public | essay_analyses | id | PRIMARY KEY |
| public | essay_analyses | essay_id | FOREIGN KEY |
| public | stories | theme_id | FOREIGN KEY |
| public | stories | subject_id | FOREIGN KEY |
| public | stories | character_id | FOREIGN KEY |
| public | stories | setting_id | FOREIGN KEY |
| public | essay_analysis_feedback | id | PRIMARY KEY |
| realtime | messages | id | PRIMARY KEY |
| realtime | messages | inserted_at | PRIMARY KEY |
| public | essay_analysis_feedback | analysis_id | FOREIGN KEY |
| public | essay_analysis_strengths | id | PRIMARY KEY |
| public | essay_analysis_strengths | analysis_id | FOREIGN KEY |
| public | essay_analysis_improvements | id | PRIMARY KEY |
| public | essay_analysis_improvements | analysis_id | FOREIGN KEY |
| public | story_subjects | null | CHECK |
| public | story_recordings | null | CHECK |
| public | story_characters | null | CHECK |
@ -174,9 +193,11 @@
| public | students | null | CHECK |
| public | phonics_word_audio | null | CHECK |
| public | languages | null | CHECK |
| public | student_essays | null | CHECK |
| pgsodium | key | null | CHECK |
| public | phonics_word_audio | null | CHECK |
| realtime | messages | null | CHECK |
| public | essay_analysis_strengths | null | CHECK |
| public | achievements | null | CHECK |
| public | phonics_exercise_types | null | CHECK |
| public | teacher_invites | null | CHECK |
@ -186,6 +207,7 @@
| auth | refresh_tokens | null | CHECK |
| pgsodium | key | null | CHECK |
| public | student_phonics_attempt_answers | null | CHECK |
| public | essay_types | null | CHECK |
| auth | identities | null | CHECK |
| storage | s3_multipart_uploads | null | CHECK |
| auth | flow_state | null | CHECK |
@ -194,6 +216,7 @@
| public | stories | null | CHECK |
| public | story_subjects | null | CHECK |
| auth | flow_state | null | CHECK |
| public | essay_analyses | null | CHECK |
| public | phonics_words | null | CHECK |
| realtime | subscription | null | CHECK |
| auth | sso_domains | null | CHECK |
@ -201,25 +224,36 @@
| auth | users | null | CHECK |
| public | phonics_categories | null | CHECK |
| public | teacher_invites | null | CHECK |
| public | essay_genres | null | CHECK |
| public | stories | null | CHECK |
| public | story_themes | null | CHECK |
| storage | s3_multipart_uploads_parts | null | CHECK |
| public | student_essays | null | CHECK |
| public | teachers | null | CHECK |
| public | teachers | null | CHECK |
| public | story_themes | null | CHECK |
| public | essay_genres | null | CHECK |
| public | phonics_exercise_words | null | CHECK |
| public | students | null | CHECK |
| realtime | subscription | null | CHECK |
| auth | mfa_amr_claims | null | CHECK |
| public | teacher_classes | null | CHECK |
| public | essay_genres | null | CHECK |
| auth | flow_state | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| public | student_essays | null | CHECK |
| public | essay_types | null | CHECK |
| public | students | null | CHECK |
| public | story_characters | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| auth | mfa_factors | null | CHECK |
| public | student_essays | null | CHECK |
| realtime | schema_migrations | null | CHECK |
| public | languages | null | CHECK |
| public | stories | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| public | phonics_word_audio | null | CHECK |
| public | essay_analysis_feedback | null | CHECK |
| pgsodium | key | null | CHECK |
| storage | s3_multipart_uploads | null | CHECK |
| auth | saml_providers | null | CHECK |
@ -232,7 +266,9 @@
| vault | secrets | null | CHECK |
| public | students | null | CHECK |
| vault | secrets | null | CHECK |
| public | essay_genres | null | CHECK |
| auth | users | null | CHECK |
| public | student_essays | null | CHECK |
| public | story_themes | null | CHECK |
| public | classes | null | CHECK |
| auth | mfa_amr_claims | null | CHECK |
@ -246,13 +282,19 @@
| public | teachers | null | CHECK |
| auth | mfa_amr_claims | null | CHECK |
| public | teacher_invites | null | CHECK |
| public | student_essays | null | CHECK |
| storage | s3_multipart_uploads_parts | null | CHECK |
| public | teachers | null | CHECK |
| public | student_essays | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| public | essay_analysis_feedback | null | CHECK |
| public | phonics_categories | null | CHECK |
| public | story_settings | null | CHECK |
| public | schools | null | CHECK |
| realtime | subscription | null | CHECK |
| public | story_recordings | null | CHECK |
| public | essay_genres | null | CHECK |
| public | essay_analysis_feedback | null | CHECK |
| public | stories | null | CHECK |
| public | story_pages | null | CHECK |
| auth | identities | null | CHECK |
@ -260,34 +302,44 @@
| auth | flow_state | null | CHECK |
| auth | one_time_tokens | null | CHECK |
| public | teacher_invites | null | CHECK |
| public | essay_genres | null | CHECK |
| auth | schema_migrations | null | CHECK |
| auth | mfa_challenges | null | CHECK |
| public | student_phonics_attempts | null | CHECK |
| public | story_settings | null | CHECK |
| public | teachers | null | CHECK |
| pgsodium | key | null | CHECK |
| public | essay_types | null | CHECK |
| public | interests | null | CHECK |
| auth | instances | null | CHECK |
| public | schools | null | CHECK |
| public | student_phonics_achievements | null | CHECK |
| storage | migrations | null | CHECK |
| public | classes | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| public | story_exercise_words | null | CHECK |
| public | phonics_word_audio | null | CHECK |
| public | media_types | null | CHECK |
| public | languages | null | CHECK |
| public | story_themes | null | CHECK |
| public | essay_analysis_strengths | null | CHECK |
| auth | one_time_tokens | null | CHECK |
| public | essay_analyses | null | CHECK |
| auth | one_time_tokens | null | CHECK |
| public | essay_analysis_feedback | null | CHECK |
| realtime | messages | null | CHECK |
| realtime | messages | null | CHECK |
| public | story_characters | null | CHECK |
| public | story_subjects | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| storage | s3_multipart_uploads_parts | null | CHECK |
| auth | sso_domains | null | CHECK |
| storage | s3_multipart_uploads_parts | null | CHECK |
| public | essay_genres | null | CHECK |
| public | teacher_invites | null | CHECK |
| public | students | null | CHECK |
| public | essay_analysis_improvements | null | CHECK |
| auth | saml_relay_states | null | CHECK |
| public | stories | null | CHECK |
| public | story_settings | null | CHECK |
@ -301,17 +353,22 @@
| public | phonics_achievements | null | CHECK |
| public | phonics_exercises | null | CHECK |
| public | story_pages | null | CHECK |
| public | essay_types | null | CHECK |
| public | student_phonics_progress | null | CHECK |
| auth | saml_providers | null | CHECK |
| public | essay_analysis_feedback | null | CHECK |
| realtime | subscription | null | CHECK |
| public | story_subjects | null | CHECK |
| public | essay_analyses | null | CHECK |
| auth | sso_providers | null | CHECK |
| public | student_essays | null | CHECK |
| public | media_types | null | CHECK |
| storage | s3_multipart_uploads | null | CHECK |
| auth | one_time_tokens | null | CHECK |
| public | story_generations | null | CHECK |
| auth | saml_relay_states | null | CHECK |
| public | story_themes | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| storage | buckets | null | CHECK |
| storage | s3_multipart_uploads_parts | null | CHECK |
| vault | secrets | null | CHECK |
@ -330,35 +387,44 @@
| public | student_phonics_attempts | null | CHECK |
| public | story_generations | null | CHECK |
| public | phonics_exercises | null | CHECK |
| public | student_essays | null | CHECK |
| auth | sso_domains | null | CHECK |
| storage | s3_multipart_uploads | null | CHECK |
| public | essay_genres | null | CHECK |
| public | schools | null | CHECK |
| auth | users | null | CHECK |
| public | interests | null | CHECK |
| realtime | messages | null | CHECK |
| public | essay_types | null | CHECK |
| public | phonics_exercise_types | null | CHECK |
| public | story_exercise_words | null | CHECK |
| auth | audit_log_entries | null | CHECK |
| public | story_characters | null | CHECK |
| public | student_achievements_old | null | CHECK |
| public | essay_types | null | CHECK |
| net | http_request_queue | null | CHECK |
| auth | saml_providers | null | CHECK |
| storage | s3_multipart_uploads | null | CHECK |
| public | essay_analysis_strengths | null | CHECK |
| supabase_migrations | seed_files | null | CHECK |
| public | phonics_achievements | null | CHECK |
| public | story_pages | null | CHECK |
| public | phonics_exercises | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| storage | objects | null | CHECK |
| public | essay_analysis_improvements | null | CHECK |
| public | student_phonics_attempt_answers | null | CHECK |
| storage | migrations | null | CHECK |
| auth | sso_domains | null | CHECK |
| public | story_recordings | null | CHECK |
| public | essay_types | null | CHECK |
| public | classes | null | CHECK |
| net | _http_response | null | CHECK |
| realtime | subscription | null | CHECK |
| public | teacher_invites | null | CHECK |
| auth | sessions | null | CHECK |
| public | phonics_exercise_media | null | CHECK |
| public | essay_analysis_improvements | null | CHECK |
| public | schools | null | CHECK |
| net | http_request_queue | null | CHECK |
| public | classes | null | CHECK |
@ -373,12 +439,16 @@
| auth | identities | null | CHECK |
| storage | s3_multipart_uploads | null | CHECK |
| public | story_themes | null | CHECK |
| public | essay_analyses | null | CHECK |
| auth | saml_relay_states | null | CHECK |
| public | essay_analyses | null | CHECK |
| public | teacher_invites | null | CHECK |
| public | essay_analysis_feedback | null | CHECK |
| auth | mfa_factors | null | CHECK |
| auth | identities | null | CHECK |
| public | stories | null | CHECK |
| public | story_pages | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| vault | secrets | null | CHECK |
| vault | secrets | null | CHECK |
| public | achievement_types | null | CHECK |
@ -388,6 +458,7 @@
| supabase_migrations | schema_migrations | null | CHECK |
| public | stories | null | CHECK |
| public | phonics_exercise_media | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| auth | mfa_challenges | null | CHECK |
| public | story_characters | null | CHECK |
| storage | s3_multipart_uploads_parts | null | CHECK |
@ -401,17 +472,23 @@
| auth | sessions | null | CHECK |
| public | story_pages | null | CHECK |
| net | http_request_queue | null | CHECK |
| public | essay_analysis_strengths | null | CHECK |
| auth | saml_providers | null | CHECK |
| public | teacher_classes | null | CHECK |
| public | story_generations | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| auth | flow_state | null | CHECK |
| public | story_characters | null | CHECK |
| storage | s3_multipart_uploads_parts | null | CHECK |
| public | student_achievements | null | CHECK |
| public | student_essays | null | CHECK |
| public | essay_analysis_scores | null | CHECK |
| public | students | null | CHECK |
| public | story_generations | null | CHECK |
| auth | one_time_tokens | null | CHECK |
| net | http_request_queue | null | CHECK |
| auth | mfa_factors | null | CHECK |
| public | essay_analysis_improvements | null | CHECK |
| public | essay_genres | null | CHECK |
| storage | s3_multipart_uploads_parts | null | CHECK |
| public | teacher_classes | null | CHECK |

View File

@ -617,14 +617,52 @@
| 29638 | schools |
| 29639 | students |
| 29639 | schools |
| 113605 | essay_types_pkey |
| 113605 | essay_types |
| 113617 | student_essays |
| 113622 | students_pkey |
| 113622 | students |
| 113627 | essay_types_pkey |
| 113627 | essay_types |
| 113632 | essay_genres_pkey |
| 113632 | essay_genres |
| 113642 | essay_analyses |
| 113647 | student_essays_pkey |
| 113647 | student_essays |
| 113661 | essay_analyses_pkey |
| 113661 | essay_analyses |
| 113675 | essay_analyses_pkey |
| 113675 | essay_analyses |
| 113689 | essay_analyses_pkey |
| 113689 | essay_analyses |
| 29709 | students_pkey |
| 29709 | students |
| 29716 | storage.objects |
| 29717 | storage.objects |
| 113699 | essay_analysis_scores |
| 113700 | essay_analysis_scores |
| 113701 | essay_analysis_scores |
| 113702 | essay_analysis_scores |
| 113703 | essay_analysis_scores |
| 113706 | essay_analyses_pkey |
| 113706 | essay_analyses |
| 29748 | story_recordings |
| 29749 | story_recordings |
| 74045 | storage.objects |
| 34119 | net.http_request_queue_id_seq |
| 113764 | essay_types |
| 113765 | essay_genres |
| 113766 | student_essays |
| 113767 | student_essays |
| 113768 | student_essays |
| 113768 | student_essays |
| 113769 | student_essays |
| 113770 | essay_analyses |
| 113770 | student_essays |
| 113770 | student_essays |
| 113771 | essay_analyses |
| 113771 | student_essays |
| 113771 | student_essays |
| 29823 | auth.users |
| 53921 | story_exercise_words |
| 53928 | stories_pkey |

View File

@ -1,6 +1,6 @@
| table_schema | table_name | column_name | data_type | is_nullable | column_default |
| ------------------- | ------------------------------- | --------------------------- | --------------------------- | ----------- | -------------------------------------------------- |
| storage | s3_multipart_uploads | user_metadata | jsonb | YES | null |
| realtime | schema_migrations | version | bigint | NO | null |
| realtime | schema_migrations | inserted_at | timestamp without time zone | YES | null |
| extensions | pg_stat_statements_info | dealloc | bigint | YES | null |
| extensions | pg_stat_statements_info | stats_reset | timestamp with time zone | YES | null |
@ -8,6 +8,23 @@
| extensions | pg_stat_statements | dbid | oid | YES | null |
| extensions | pg_stat_statements | toplevel | boolean | YES | null |
| extensions | pg_stat_statements | queryid | bigint | YES | null |
| public | teachers | updated_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | teachers | class_ids | ARRAY | YES | null |
| public | classes | id | uuid | NO | uuid_generate_v4() |
| public | classes | school_id | uuid | NO | null |
| public | classes | year | integer | NO | null |
| public | classes | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | classes | updated_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | classes | teacher_id | uuid | YES | null |
| public | essay_analysis_scores | id | uuid | NO | uuid_generate_v4() |
| public | essay_analysis_scores | analysis_id | uuid | NO | null |
| public | essay_analysis_scores | adequacy | integer | NO | null |
| public | essay_analysis_scores | coherence | integer | NO | null |
| public | essay_analysis_scores | cohesion | integer | NO | null |
| public | essay_analysis_scores | vocabulary | integer | NO | null |
| public | essay_analysis_scores | grammar | integer | NO | null |
| public | essay_analysis_scores | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | teacher_classes | id | uuid | NO | uuid_generate_v4() |
| public | teacher_classes | teacher_id | uuid | NO | null |
| public | teacher_classes | class_id | uuid | NO | null |
| public | teacher_classes | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
@ -233,7 +250,7 @@
| storage | s3_multipart_uploads_parts | created_at | timestamp with time zone | NO | now() |
| storage | s3_multipart_uploads | in_progress_size | bigint | NO | 0 |
| storage | s3_multipart_uploads | created_at | timestamp with time zone | NO | now() |
| realtime | schema_migrations | version | bigint | NO | null |
| storage | s3_multipart_uploads | user_metadata | jsonb | YES | null |
| extensions | pg_stat_statements | plans | bigint | YES | null |
| extensions | pg_stat_statements | total_plan_time | double precision | YES | null |
| extensions | pg_stat_statements | min_plan_time | double precision | YES | null |
@ -338,7 +355,17 @@
| storage | objects | user_metadata | jsonb | YES | null |
| storage | migrations | id | integer | NO | null |
| storage | migrations | executed_at | timestamp without time zone | YES | CURRENT_TIMESTAMP |
| public | essay_types | id | uuid | NO | uuid_generate_v4() |
| public | essay_types | active | boolean | YES | true |
| public | essay_types | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | essay_types | updated_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| pgsodium | mask_columns | attrelid | oid | YES | null |
| public | essay_genres | id | uuid | NO | uuid_generate_v4() |
| public | essay_genres | type_id | uuid | NO | null |
| public | essay_genres | requirements | jsonb | NO | '{}'::jsonb |
| public | essay_genres | active | boolean | YES | true |
| public | essay_genres | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | essay_genres | updated_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| pgsodium | valid_key | id | uuid | YES | null |
| pgsodium | valid_key | status | USER-DEFINED | YES | null |
| pgsodium | valid_key | key_type | USER-DEFINED | YES | null |
@ -346,6 +373,12 @@
| pgsodium | valid_key | key_context | bytea | YES | null |
| pgsodium | valid_key | created | timestamp with time zone | YES | null |
| pgsodium | valid_key | expires | timestamp with time zone | YES | null |
| public | student_essays | id | uuid | NO | uuid_generate_v4() |
| public | student_essays | student_id | uuid | NO | null |
| public | student_essays | type_id | uuid | NO | null |
| public | student_essays | genre_id | uuid | NO | null |
| public | student_essays | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | student_essays | updated_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| pgsodium | decrypted_key | id | uuid | YES | null |
| pgsodium | decrypted_key | status | USER-DEFINED | YES | null |
| pgsodium | decrypted_key | created | timestamp with time zone | YES | null |
@ -404,71 +437,89 @@
| public | story_pages | story_id | uuid | YES | null |
| public | story_pages | page_number | integer | NO | null |
| public | story_pages | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | essay_analyses | id | uuid | NO | uuid_generate_v4() |
| public | essay_analyses | essay_id | uuid | NO | null |
| public | essay_analyses | overall_score | integer | NO | null |
| public | essay_analyses | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | essay_analysis_feedback | id | uuid | NO | uuid_generate_v4() |
| public | essay_analysis_feedback | analysis_id | uuid | NO | null |
| public | essay_analysis_feedback | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | schools | id | uuid | NO | uuid_generate_v4() |
| public | schools | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | schools | updated_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | essay_analysis_strengths | id | uuid | NO | uuid_generate_v4() |
| public | essay_analysis_strengths | analysis_id | uuid | NO | null |
| public | essay_analysis_strengths | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | essay_analysis_improvements | id | uuid | NO | uuid_generate_v4() |
| public | essay_analysis_improvements | analysis_id | uuid | NO | null |
| public | essay_analysis_improvements | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | teachers | id | uuid | NO | uuid_generate_v4() |
| public | teachers | school_id | uuid | NO | null |
| public | teachers | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | teachers | updated_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | teachers | class_ids | ARRAY | YES | null |
| public | classes | id | uuid | NO | uuid_generate_v4() |
| public | classes | school_id | uuid | NO | null |
| public | classes | year | integer | NO | null |
| public | classes | created_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | classes | updated_at | timestamp with time zone | NO | timezone('utc'::text, now()) |
| public | classes | teacher_id | uuid | YES | null |
| public | teacher_classes | id | uuid | NO | uuid_generate_v4() |
| vault | secrets | secret | text | NO | null |
| public | story_themes | icon | text | NO | null |
| public | story_recordings | improvements | ARRAY | YES | null |
| public | achievement_types | name | character varying | NO | null |
| public | achievement_types | description | text | YES | null |
| public | story_recordings | suggestions | text | YES | null |
| vault | decrypted_secrets | name | text | YES | null |
| vault | decrypted_secrets | description | text | YES | null |
| vault | decrypted_secrets | secret | text | YES | null |
| pgsodium | key | associated_data | text | YES | 'associated'::text |
| storage | s3_multipart_uploads_parts | bucket_id | text | NO | null |
| auth | sso_domains | domain | text | NO | null |
| auth | flow_state | authentication_method | text | NO | null |
| pgsodium | key | comment | text | YES | null |
| pgsodium | key | user_data | text | YES | null |
| public | stories | context | text | YES | null |
| public | student_phonics_attempt_answers | answer_text | text | YES | null |
| public | story_recordings | strengths | ARRAY | YES | null |
| pgsodium | masking_rule | format_type | text | YES | null |
| public | story_themes | slug | text | NO | null |
| public | story_themes | title | text | NO | null |
| public | story_themes | description | text | NO | null |
| vault | secrets | name | text | YES | null |
| vault | secrets | description | text | NO | ''::text |
| extensions | pg_stat_statements | query | text | YES | null |
| public | story_subjects | slug | text | NO | null |
| public | story_subjects | title | text | NO | null |
| public | story_subjects | description | text | NO | null |
| public | story_subjects | icon | text | NO | null |
| realtime | messages | topic | text | NO | null |
| realtime | messages | extension | text | NO | null |
| realtime | messages | event | text | YES | null |
| auth | mfa_factors | secret | text | YES | null |
| public | interests | category | text | NO | null |
| public | interests | item | text | NO | null |
| public | story_characters | slug | text | NO | null |
| public | story_characters | title | text | NO | null |
| public | story_characters | description | text | NO | null |
| public | story_generations | original_prompt | text | NO | null |
| public | story_generations | ai_response | text | NO | null |
| public | story_generations | model_used | text | NO | null |
| public | story_characters | icon | text | NO | null |
| auth | mfa_factors | phone | text | YES | null |
| auth | identities | provider_id | text | NO | null |
| net | http_request_queue | method | text | NO | null |
| net | http_request_queue | url | text | NO | null |
| storage | s3_multipart_uploads_parts | etag | text | NO | null |
| public | achievements | name | text | YES | null |
| public | story_settings | slug | text | NO | null |
| public | story_settings | title | text | NO | null |
| public | story_settings | description | text | NO | null |
| net | _http_response | content_type | text | YES | null |
| public | story_settings | icon | text | NO | null |
| net | _http_response | content | text | YES | null |
| public | achievements | description | text | YES | null |
| public | story_details | title | text | YES | null |
| net | _http_response | error_msg | text | YES | null |
| public | story_details | status | text | YES | null |
| storage | s3_multipart_uploads_parts | owner_id | text | YES | null |
| auth | mfa_amr_claims | authentication_method | text | NO | null |
| auth | identities | provider | text | NO | null |
| storage | s3_multipart_uploads_parts | version | text | NO | null |
| realtime | messages | topic | text | NO | null |
| realtime | messages | extension | text | NO | null |
| public | teacher_invites | email | text | NO | null |
| realtime | messages | event | text | YES | null |
| public | teacher_invites | name | text | NO | null |
| public | teacher_invites | subject | text | YES | null |
| public | teacher_invites | message | text | YES | null |
| public | teacher_invites | status | text | YES | 'pending'::text |
| public | teacher_invites | token | text | NO | null |
| storage | s3_multipart_uploads | version | text | NO | null |
| public | story_generations | original_prompt | text | NO | null |
| public | story_generations | ai_response | text | NO | null |
| public | story_generations | model_used | text | NO | null |
| auth | saml_providers | entity_id | text | NO | null |
| auth | mfa_challenges | otp_code | text | YES | null |
| auth | saml_providers | metadata_xml | text | NO | null |
| net | http_request_queue | method | text | NO | null |
| net | http_request_queue | url | text | NO | null |
| auth | saml_providers | metadata_url | text | YES | null |
| public | phonics_exercises | title | character varying | NO | null |
| public | phonics_exercises | description | text | YES | null |
| public | phonics_achievements | name | character varying | NO | null |
| public | phonics_achievements | description | text | YES | null |
| net | _http_response | content_type | text | YES | null |
| public | phonics_exercises | instructions | text | NO | null |
| net | _http_response | content | text | YES | null |
| storage | s3_multipart_uploads | id | text | NO | null |
| net | _http_response | error_msg | text | YES | null |
| public | phonics_achievements | icon_url | text | YES | null |
| auth | identities | email | text | YES | null |
| storage | s3_multipart_uploads | owner_id | text | YES | null |
| auth | saml_relay_states | request_id | text | NO | null |
| public | story_pages | text | text | NO | null |
| public | story_pages | image_url | text | NO | null |
| auth | saml_relay_states | for_email | text | YES | null |
| public | story_pages | image_path | text | YES | null |
| public | story_pages | image_url_thumb | text | YES | null |
| public | story_details | title | text | YES | null |
| public | story_pages | image_url_medium | text | YES | null |
| public | story_details | status | text | YES | null |
| public | story_pages | image_url_large | text | YES | null |
| public | story_pages | image_path_thumb | text | YES | null |
| public | story_pages | image_path_medium | text | YES | null |
| public | story_pages | image_path_large | text | YES | null |
| public | story_pages | text_syllables | text | YES | null |
| public | phonics_exercise_types | name | character varying | NO | null |
| public | story_details | context | text | YES | null |
| public | story_details | theme_title | text | YES | null |
| public | story_details | theme_icon | text | YES | null |
@ -478,70 +529,70 @@
| public | story_details | character_icon | text | YES | null |
| public | story_details | setting_title | text | YES | null |
| public | story_details | setting_icon | text | YES | null |
| public | schools | name | text | NO | null |
| public | schools | address | text | YES | null |
| public | teacher_invites | email | text | NO | null |
| public | story_pages | image_path | text | YES | null |
| public | story_exercise_words | word | text | NO | null |
| public | story_exercise_words | exercise_type | text | NO | null |
| public | story_exercise_words | phonemes | ARRAY | YES | null |
| public | story_exercise_words | syllable_pattern | text | YES | null |
| public | schools | phone | text | YES | null |
| public | schools | email | text | YES | null |
| public | phonics_exercise_types | description | text | YES | null |
| auth | saml_relay_states | redirect_to | text | YES | null |
| public | schools | director_name | text | NO | 'Não informado'::text |
| auth | audit_log_entries | ip_address | character varying | NO | ''::character varying |
| public | phonics_words | word | character varying | NO | null |
| public | teachers | name | text | NO | null |
| public | story_pages | image_url_thumb | text | YES | null |
| public | story_pages | image_url_medium | text | YES | null |
| public | story_pages | image_url_large | text | YES | null |
| public | story_pages | image_path_thumb | text | YES | null |
| public | story_pages | image_path_medium | text | YES | null |
| public | story_pages | image_path_large | text | YES | null |
| public | story_pages | text_syllables | text | YES | null |
| public | teacher_invites | name | text | NO | null |
| auth | schema_migrations | version | character varying | NO | null |
| public | teachers | email | text | NO | null |
| public | teachers | phone | text | YES | null |
| public | teacher_invites | subject | text | YES | null |
| public | teacher_invites | message | text | YES | null |
| auth | instances | raw_base_config | text | YES | null |
| public | teachers | subject | text | YES | null |
| public | phonics_words | phonetic_transcription | character varying | YES | null |
| auth | saml_providers | name_id_format | text | YES | null |
| public | teachers | status | text | YES | 'pending'::text |
| public | essay_analyses | suggestions | text | YES | null |
| public | teacher_invites | status | text | YES | 'pending'::text |
| public | teacher_invites | token | text | NO | null |
| storage | s3_multipart_uploads | version | text | NO | null |
| auth | users | aud | character varying | YES | null |
| auth | users | role | character varying | YES | null |
| auth | users | email | character varying | YES | null |
| auth | users | encrypted_password | character varying | YES | null |
| public | phonics_word_audio | word | text | NO | null |
| public | phonics_word_audio | audio_url | text | NO | null |
| public | essay_analysis_feedback | structure_feedback | text | NO | null |
| public | essay_analysis_feedback | content_feedback | text | NO | null |
| auth | users | confirmation_token | character varying | YES | null |
| public | phonics_word_audio | audio_path | text | NO | null |
| public | essay_analysis_feedback | language_feedback | text | NO | null |
| auth | users | recovery_token | character varying | YES | null |
| public | classes | name | text | NO | null |
| auth | saml_providers | entity_id | text | NO | null |
| auth | users | email_change_token_new | character varying | YES | null |
| auth | users | email_change | character varying | YES | null |
| public | classes | grade | text | NO | null |
| storage | s3_multipart_uploads | upload_signature | text | NO | null |
| public | classes | period | text | YES | null |
| storage | s3_multipart_uploads_parts | upload_id | text | NO | null |
| public | languages | name | character varying | NO | null |
| public | languages | code | character varying | NO | null |
| public | languages | instructions | text | YES | null |
| auth | mfa_challenges | otp_code | text | YES | null |
| public | schools | name | text | NO | null |
| public | schools | address | text | YES | null |
| public | schools | phone | text | YES | null |
| public | schools | email | text | YES | null |
| auth | saml_providers | metadata_xml | text | NO | null |
| auth | saml_providers | metadata_url | text | YES | null |
| auth | users | phone | text | YES | NULL::character varying |
| public | phonics_categories | name | character varying | NO | null |
| public | schools | director_name | text | NO | 'Não informado'::text |
| auth | users | phone_change | text | YES | ''::character varying |
| auth | users | phone_change_token | character varying | YES | ''::character varying |
| public | phonics_categories | description | text | YES | null |
| public | languages | flag_icon | character varying | YES | null |
| public | phonics_exercises | title | character varying | NO | null |
| public | phonics_exercises | description | text | YES | null |
| auth | users | email_change_token_current | character varying | YES | ''::character varying |
| auth | sso_providers | resource_id | text | YES | null |
| auth | flow_state | auth_code | text | NO | null |
| public | essay_analysis_strengths | strength | text | NO | null |
| public | phonics_achievements | name | character varying | NO | null |
| auth | users | reauthentication_token | character varying | YES | ''::character varying |
| public | students | name | text | NO | null |
| public | students | email | text | NO | null |
| storage | s3_multipart_uploads | bucket_id | text | NO | null |
| public | students | guardian_name | text | YES | null |
| public | students | guardian_phone | text | YES | null |
| public | students | guardian_email | text | YES | null |
| public | phonics_achievements | description | text | YES | null |
| public | phonics_exercises | instructions | text | NO | null |
| public | essay_analysis_improvements | improvement | text | NO | null |
| storage | s3_multipart_uploads | id | text | NO | null |
| public | phonics_achievements | icon_url | text | YES | null |
| auth | identities | email | text | YES | null |
| auth | refresh_tokens | token | character varying | YES | null |
| auth | refresh_tokens | user_id | character varying | YES | null |
| auth | flow_state | code_challenge | text | NO | null |
| auth | flow_state | provider_type | text | NO | null |
| public | students | status | text | NO | 'active'::text |
| public | teachers | name | text | NO | null |
| public | teachers | email | text | NO | null |
| public | teachers | phone | text | YES | null |
| auth | refresh_tokens | parent | character varying | YES | null |
| public | story_recordings | audio_url | text | YES | null |
| public | teachers | subject | text | YES | null |
| supabase_migrations | seed_files | path | text | NO | null |
| supabase_migrations | seed_files | hash | text | NO | null |
| supabase_migrations | schema_migrations | version | text | NO | null |
@ -549,99 +600,102 @@
| supabase_migrations | schema_migrations | name | text | YES | null |
| storage | buckets | id | text | NO | null |
| storage | buckets | name | text | NO | null |
| storage | s3_multipart_uploads | owner_id | text | YES | null |
| auth | saml_relay_states | request_id | text | NO | null |
| public | teachers | status | text | YES | 'pending'::text |
| auth | saml_relay_states | for_email | text | YES | null |
| public | phonics_exercise_types | name | character varying | NO | null |
| public | phonics_exercise_types | description | text | YES | null |
| storage | buckets | allowed_mime_types | ARRAY | YES | null |
| storage | buckets | owner_id | text | YES | null |
| public | classes | name | text | NO | null |
| storage | objects | bucket_id | text | YES | null |
| storage | objects | name | text | YES | null |
| public | classes | grade | text | NO | null |
| auth | saml_relay_states | redirect_to | text | YES | null |
| public | classes | period | text | YES | null |
| auth | audit_log_entries | ip_address | character varying | NO | ''::character varying |
| public | phonics_words | word | character varying | NO | null |
| storage | objects | path_tokens | ARRAY | YES | null |
| storage | objects | version | text | YES | null |
| storage | objects | owner_id | text | YES | null |
| public | phonics_words | phonetic_transcription | character varying | YES | null |
| auth | saml_providers | name_id_format | text | YES | null |
| storage | migrations | name | character varying | NO | null |
| storage | migrations | hash | character varying | NO | null |
| public | phonics_word_audio | word | text | NO | null |
| public | phonics_word_audio | audio_url | text | NO | null |
| public | essay_types | slug | text | NO | null |
| public | essay_types | title | text | NO | null |
| public | essay_types | description | text | NO | null |
| public | essay_types | icon | text | NO | null |
| public | phonics_word_audio | audio_path | text | NO | null |
| storage | s3_multipart_uploads | upload_signature | text | NO | null |
| storage | s3_multipart_uploads_parts | upload_id | text | NO | null |
| public | languages | name | character varying | NO | null |
| pgsodium | mask_columns | format_type | text | YES | null |
| public | languages | code | character varying | NO | null |
| public | languages | instructions | text | YES | null |
| public | essay_genres | slug | text | NO | null |
| public | essay_genres | title | text | NO | null |
| public | essay_genres | description | text | NO | null |
| public | essay_genres | icon | text | NO | null |
| public | phonics_categories | name | character varying | NO | null |
| public | phonics_categories | description | text | YES | null |
| public | languages | flag_icon | character varying | YES | null |
| auth | sso_providers | resource_id | text | YES | null |
| auth | flow_state | auth_code | text | NO | null |
| pgsodium | valid_key | name | text | YES | null |
| public | students | name | text | NO | null |
| public | students | email | text | NO | null |
| storage | s3_multipart_uploads | bucket_id | text | NO | null |
| public | students | guardian_name | text | YES | null |
| public | students | guardian_phone | text | YES | null |
| public | students | guardian_email | text | YES | null |
| pgsodium | valid_key | associated_data | text | YES | null |
| auth | flow_state | code_challenge | text | NO | null |
| auth | flow_state | provider_type | text | NO | null |
| public | students | status | text | NO | 'active'::text |
| public | story_recordings | audio_url | text | YES | null |
| public | student_essays | title | text | NO | null |
| public | student_essays | content | text | NO | null |
| public | student_essays | status | text | NO | 'draft'::text |
| public | students | avatar_url | text | YES | null |
| public | students | nickname | character varying | YES | null |
| public | phonics_exercise_media | url | text | NO | null |
| public | phonics_exercise_media | alt_text | text | YES | null |
| public | students | preferred_themes | ARRAY | YES | null |
| public | story_recordings | status | text | NO | 'pending_analysis'::text |
| storage | buckets | allowed_mime_types | ARRAY | YES | null |
| storage | buckets | owner_id | text | YES | null |
| auth | flow_state | provider_access_token | text | YES | null |
| storage | objects | bucket_id | text | YES | null |
| storage | objects | name | text | YES | null |
| auth | one_time_tokens | token_hash | text | NO | null |
| public | media_types | name | character varying | NO | null |
| pgsodium | decrypted_key | name | text | YES | null |
| pgsodium | decrypted_key | associated_data | text | YES | null |
| public | media_types | description | text | YES | null |
| public | story_recordings | transcription | text | YES | null |
| auth | one_time_tokens | relates_to | text | NO | null |
| storage | objects | path_tokens | ARRAY | YES | null |
| storage | objects | version | text | YES | null |
| storage | objects | owner_id | text | YES | null |
| public | story_recordings | error_message | text | YES | null |
| pgsodium | decrypted_key | comment | text | YES | null |
| auth | sessions | user_agent | text | YES | null |
| storage | migrations | name | character varying | NO | null |
| storage | migrations | hash | character varying | NO | null |
| public | stories | title | text | NO | null |
| pgsodium | masking_rule | format_type | text | YES | null |
| auth | identities | provider_id | text | NO | null |
| storage | s3_multipart_uploads_parts | etag | text | NO | null |
| public | achievements | name | text | YES | null |
| vault | secrets | name | text | YES | null |
| vault | secrets | description | text | NO | ''::text |
| vault | secrets | secret | text | NO | null |
| public | story_settings | slug | text | NO | null |
| public | story_settings | title | text | NO | null |
| public | story_settings | description | text | NO | null |
| public | story_settings | icon | text | NO | null |
| public | achievements | description | text | YES | null |
| vault | decrypted_secrets | name | text | YES | null |
| vault | decrypted_secrets | description | text | YES | null |
| vault | decrypted_secrets | secret | text | YES | null |
| extensions | pg_stat_statements | query | text | YES | null |
| auth | flow_state | provider_refresh_token | text | YES | null |
| pgsodium | mask_columns | format_type | text | YES | null |
| public | stories | status | text | YES | 'draft'::text |
| pgsodium | valid_key | name | text | YES | null |
| auth | sessions | tag | text | YES | null |
| auth | mfa_factors | friendly_name | text | YES | null |
| storage | s3_multipart_uploads_parts | bucket_id | text | NO | null |
| auth | sso_domains | domain | text | NO | null |
| auth | flow_state | authentication_method | text | NO | null |
| pgsodium | valid_key | associated_data | text | YES | null |
| public | stories | context | text | YES | null |
| public | student_phonics_attempt_answers | answer_text | text | YES | null |
| public | story_recordings | strengths | ARRAY | YES | null |
| public | story_themes | slug | text | NO | null |
| public | story_themes | title | text | NO | null |
| public | story_themes | description | text | NO | null |
| public | story_themes | icon | text | NO | null |
| pgsodium | decrypted_key | name | text | YES | null |
| pgsodium | decrypted_key | associated_data | text | YES | null |
| public | story_recordings | improvements | ARRAY | YES | null |
| public | achievement_types | name | character varying | NO | null |
| public | achievement_types | description | text | YES | null |
| public | story_recordings | suggestions | text | YES | null |
| pgsodium | decrypted_key | comment | text | YES | null |
| public | story_subjects | slug | text | NO | null |
| public | story_subjects | title | text | NO | null |
| public | story_subjects | description | text | NO | null |
| public | story_subjects | icon | text | NO | null |
| auth | mfa_factors | secret | text | YES | null |
| public | interests | category | text | NO | null |
| pgsodium | key | name | text | YES | null |
| pgsodium | key | associated_data | text | YES | 'associated'::text |
| public | interests | item | text | NO | null |
| public | story_characters | slug | text | NO | null |
| public | story_characters | title | text | NO | null |
| pgsodium | key | comment | text | YES | null |
| pgsodium | key | user_data | text | YES | null |
| public | story_characters | description | text | NO | null |
| public | story_characters | icon | text | NO | null |
| auth | mfa_factors | phone | text | YES | null |
| pgsodium | mask_columns | attname | name | YES | null |
| pgsodium | masking_rule | nonce_column | text | YES | null |
| pgsodium | mask_columns | key_id | text | YES | null |
| vault | decrypted_secrets | decrypted_secret | text | YES | null |
| pgsodium | mask_columns | key_id_column | text | YES | null |
| pgsodium | mask_columns | associated_columns | text | YES | null |
| pgsodium | mask_columns | nonce_column | text | YES | null |
| pgsodium | masking_rule | view_name | text | YES | null |
| pgsodium | masking_rule | relname | name | YES | null |
| pgsodium | masking_rule | attname | name | YES | null |
| vault | decrypted_secrets | decrypted_secret | text | YES | null |
| storage | s3_multipart_uploads | key | text | NO | null |
| pgsodium | masking_rule | col_description | text | YES | null |
| pgsodium | masking_rule | key_id_column | text | YES | null |
| pgsodium | masking_rule | key_id | text | YES | null |
| storage | s3_multipart_uploads_parts | key | text | NO | null |
| pgsodium | masking_rule | associated_columns | text | YES | null |
| storage | s3_multipart_uploads | key | text | NO | null |
| pgsodium | masking_rule | key_id | text | YES | null |
| pgsodium | masking_rule | associated_columns | text | YES | null |
| pgsodium | masking_rule | nonce_column | text | YES | null |
| pgsodium | masking_rule | relname | name | YES | null |
| pgsodium | mask_columns | attname | name | YES | null |
| pgsodium | masking_rule | attname | name | YES | null |
| pgsodium | mask_columns | key_id | text | YES | null |

View File

@ -63,18 +63,18 @@ WITH
SELECT extensions.url_encode(extensions.hmac(signables, secret, alg.id)) FROM alg;
$function$
|
| extensions | armor | CREATE OR REPLACE FUNCTION extensions.armor(bytea, text[], text[])
RETURNS text
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pg_armor$function$
|
| extensions | armor | CREATE OR REPLACE FUNCTION extensions.armor(bytea)
RETURNS text
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pg_armor$function$
|
| extensions | armor | CREATE OR REPLACE FUNCTION extensions.armor(bytea, text[], text[])
RETURNS text
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pg_armor$function$
|
| extensions | bytea_to_text | CREATE OR REPLACE FUNCTION extensions.bytea_to_text(data bytea)
RETURNS text
LANGUAGE c
@ -296,16 +296,21 @@ AS '$libdir/pgcrypto', $function$pg_hmac$function$
LANGUAGE c
AS '$libdir/http', $function$http_request$function$
|
| extensions | http_delete | CREATE OR REPLACE FUNCTION extensions.http_delete(uri character varying, content character varying, content_type character varying)
RETURNS http_response
LANGUAGE sql
AS $function$ SELECT extensions.http(('DELETE', $1, NULL, $3, $2)::extensions.http_request) $function$
|
| extensions | http_delete | CREATE OR REPLACE FUNCTION extensions.http_delete(uri character varying)
RETURNS http_response
LANGUAGE sql
AS $function$ SELECT extensions.http(('DELETE', $1, NULL, NULL, NULL)::extensions.http_request) $function$
|
| extensions | http_delete | CREATE OR REPLACE FUNCTION extensions.http_delete(uri character varying, content character varying, content_type character varying)
RETURNS http_response
LANGUAGE sql
AS $function$ SELECT extensions.http(('DELETE', $1, NULL, $3, $2)::extensions.http_request) $function$
|
| extensions | http_get | CREATE OR REPLACE FUNCTION extensions.http_get(uri character varying)
RETURNS http_response
LANGUAGE sql
AS $function$ SELECT extensions.http(('GET', $1, NULL, NULL, NULL)::extensions.http_request) $function$
|
| extensions | http_get | CREATE OR REPLACE FUNCTION extensions.http_get(uri character varying, data jsonb)
RETURNS http_response
LANGUAGE sql
@ -313,11 +318,6 @@ AS $function$
SELECT extensions.http(('GET', $1 || '?' || extensions.urlencode($2), NULL, NULL, NULL)::extensions.http_request)
$function$
|
| extensions | http_get | CREATE OR REPLACE FUNCTION extensions.http_get(uri character varying)
RETURNS http_response
LANGUAGE sql
AS $function$ SELECT extensions.http(('GET', $1, NULL, NULL, NULL)::extensions.http_request) $function$
|
| extensions | http_head | CREATE OR REPLACE FUNCTION extensions.http_head(uri character varying)
RETURNS http_response
LANGUAGE sql
@ -338,11 +338,6 @@ AS '$libdir/http', $function$http_list_curlopt$function$
LANGUAGE sql
AS $function$ SELECT extensions.http(('PATCH', $1, NULL, $3, $2)::extensions.http_request) $function$
|
| extensions | http_post | CREATE OR REPLACE FUNCTION extensions.http_post(uri character varying, content character varying, content_type character varying)
RETURNS http_response
LANGUAGE sql
AS $function$ SELECT extensions.http(('POST', $1, NULL, $3, $2)::extensions.http_request) $function$
|
| extensions | http_post | CREATE OR REPLACE FUNCTION extensions.http_post(uri character varying, data jsonb)
RETURNS http_response
LANGUAGE sql
@ -350,6 +345,11 @@ AS $function$
SELECT extensions.http(('POST', $1, NULL, 'application/x-www-form-urlencoded', extensions.urlencode($2))::extensions.http_request)
$function$
|
| extensions | http_post | CREATE OR REPLACE FUNCTION extensions.http_post(uri character varying, content character varying, content_type character varying)
RETURNS http_response
LANGUAGE sql
AS $function$ SELECT extensions.http(('POST', $1, NULL, $3, $2)::extensions.http_request) $function$
|
| extensions | http_put | CREATE OR REPLACE FUNCTION extensions.http_put(uri character varying, content character varying, content_type character varying)
RETURNS http_response
LANGUAGE sql
@ -395,12 +395,6 @@ AS '$libdir/pgcrypto', $function$pgp_armor_headers$function$
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_key_id_w$function$
|
| extensions | pgp_pub_decrypt | CREATE OR REPLACE FUNCTION extensions.pgp_pub_decrypt(bytea, bytea, text, text)
RETURNS text
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_decrypt_text$function$
|
| extensions | pgp_pub_decrypt | CREATE OR REPLACE FUNCTION extensions.pgp_pub_decrypt(bytea, bytea)
RETURNS text
LANGUAGE c
@ -413,24 +407,30 @@ AS '$libdir/pgcrypto', $function$pgp_pub_decrypt_text$function$
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_decrypt_text$function$
|
| extensions | pgp_pub_decrypt | CREATE OR REPLACE FUNCTION extensions.pgp_pub_decrypt(bytea, bytea, text, text)
RETURNS text
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_decrypt_text$function$
|
| extensions | pgp_pub_decrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea)
RETURNS bytea
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_decrypt_bytea$function$
|
| extensions | pgp_pub_decrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea, text, text)
RETURNS bytea
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_decrypt_bytea$function$
|
| extensions | pgp_pub_decrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea, text)
RETURNS bytea
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_decrypt_bytea$function$
|
| extensions | pgp_pub_decrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_pub_decrypt_bytea(bytea, bytea, text, text)
RETURNS bytea
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_decrypt_bytea$function$
|
| extensions | pgp_pub_encrypt | CREATE OR REPLACE FUNCTION extensions.pgp_pub_encrypt(text, bytea)
RETURNS bytea
LANGUAGE c
@ -443,18 +443,18 @@ AS '$libdir/pgcrypto', $function$pgp_pub_encrypt_text$function$
PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_encrypt_text$function$
|
| extensions | pgp_pub_encrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_pub_encrypt_bytea(bytea, bytea, text)
RETURNS bytea
LANGUAGE c
PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_encrypt_bytea$function$
|
| extensions | pgp_pub_encrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_pub_encrypt_bytea(bytea, bytea)
RETURNS bytea
LANGUAGE c
PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_encrypt_bytea$function$
|
| extensions | pgp_pub_encrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_pub_encrypt_bytea(bytea, bytea, text)
RETURNS bytea
LANGUAGE c
PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_pub_encrypt_bytea$function$
|
| extensions | pgp_sym_decrypt | CREATE OR REPLACE FUNCTION extensions.pgp_sym_decrypt(bytea, text)
RETURNS text
LANGUAGE c
@ -467,42 +467,42 @@ AS '$libdir/pgcrypto', $function$pgp_sym_decrypt_text$function$
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_sym_decrypt_text$function$
|
| extensions | pgp_sym_decrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_sym_decrypt_bytea(bytea, text, text)
RETURNS bytea
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_sym_decrypt_bytea$function$
|
| extensions | pgp_sym_decrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_sym_decrypt_bytea(bytea, text)
RETURNS bytea
LANGUAGE c
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_sym_decrypt_bytea$function$
|
| extensions | pgp_sym_encrypt | CREATE OR REPLACE FUNCTION extensions.pgp_sym_encrypt(text, text, text)
| extensions | pgp_sym_decrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_sym_decrypt_bytea(bytea, text, text)
RETURNS bytea
LANGUAGE c
PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_sym_encrypt_text$function$
|
IMMUTABLE PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_sym_decrypt_bytea$function$
|
| extensions | pgp_sym_encrypt | CREATE OR REPLACE FUNCTION extensions.pgp_sym_encrypt(text, text)
RETURNS bytea
LANGUAGE c
PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_sym_encrypt_text$function$
|
| extensions | pgp_sym_encrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_sym_encrypt_bytea(bytea, text, text)
| extensions | pgp_sym_encrypt | CREATE OR REPLACE FUNCTION extensions.pgp_sym_encrypt(text, text, text)
RETURNS bytea
LANGUAGE c
PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_sym_encrypt_bytea$function$
|
AS '$libdir/pgcrypto', $function$pgp_sym_encrypt_text$function$
|
| extensions | pgp_sym_encrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_sym_encrypt_bytea(bytea, text)
RETURNS bytea
LANGUAGE c
PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_sym_encrypt_bytea$function$
|
| extensions | pgp_sym_encrypt_bytea | CREATE OR REPLACE FUNCTION extensions.pgp_sym_encrypt_bytea(bytea, text, text)
RETURNS bytea
LANGUAGE c
PARALLEL SAFE STRICT
AS '$libdir/pgcrypto', $function$pgp_sym_encrypt_bytea$function$
|
| extensions | pgrst_ddl_watch | CREATE OR REPLACE FUNCTION extensions.pgrst_ddl_watch()
RETURNS event_trigger
LANGUAGE plpgsql
@ -675,6 +675,12 @@ AS $function$
SELECT translate(encode(data, 'base64'), E'+/=\n', '-_');
$function$
|
| extensions | urlencode | CREATE OR REPLACE FUNCTION extensions.urlencode(string bytea)
RETURNS text
LANGUAGE c
IMMUTABLE STRICT
AS '$libdir/http', $function$urlencode$function$
|
| extensions | urlencode | CREATE OR REPLACE FUNCTION extensions.urlencode(string character varying)
RETURNS text
LANGUAGE c
@ -687,12 +693,6 @@ AS '$libdir/http', $function$urlencode$function$
IMMUTABLE STRICT
AS '$libdir/http', $function$urlencode_jsonb$function$
|
| extensions | urlencode | CREATE OR REPLACE FUNCTION extensions.urlencode(string bytea)
RETURNS text
LANGUAGE c
IMMUTABLE STRICT
AS '$libdir/http', $function$urlencode$function$
|
| extensions | uuid_generate_v1 | CREATE OR REPLACE FUNCTION extensions.uuid_generate_v1()
RETURNS uuid
LANGUAGE c

View File

@ -1,31 +1,39 @@
| policy_id | schema_name | table_name | policy_name | command | policy_using | policy_check |
| --------- | ----------- | ---------------------- | ------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| 29823 | auth | users | Validate user metadata | * | (((raw_user_meta_data ->> 'role'::text))::user_role IS NOT NULL) | null |
| 29615 | public | classes | Schools can create classes | a | null | (school_id = auth.uid()) |
| 29616 | public | classes | Schools can update their classes | w | (school_id = auth.uid()) | (school_id = auth.uid()) |
| 29614 | public | classes | Schools can view their classes | r | (school_id = auth.uid()) | null |
| 29301 | public | classes | Turmas visíveis para usuários autenticados | r | true | null |
| 65878 | public | interests | Students can delete their own interests | d | (auth.uid() = student_id) | null |
| 65876 | public | interests | Students can insert their own interests | a | null | (auth.uid() = student_id) |
| 65877 | public | interests | Students can update their own interests | w | (auth.uid() = student_id) | (auth.uid() = student_id) |
| 65875 | public | interests | Students can view their own interests | r | (auth.uid() = student_id) | null |
| 104599 | public | languages | Allow insert/update for admins only | * | ((auth.jwt() ->> 'role'::text) = 'admin'::text) | ((auth.jwt() ->> 'role'::text) = 'admin'::text) |
| 104598 | public | languages | Allow read access for all authenticated users | r | true | null |
| 79931 | public | phonics_categories | Permitir leitura de categorias fonéticas para usuários autent | r | true | null |
| 79932 | public | phonics_exercise_types | Permitir leitura de tipos de exercícios fonéticos para usuár | r | true | null |
| 79934 | public | phonics_exercise_words | Permitir leitura de relações exercício-palavra para usuário | r | true | null |
| 79930 | public | phonics_exercises | Permitir leitura de exercícios fonéticos para usuários auten | r | true | null |
| 79933 | public | phonics_words | Permitir leitura de palavras fonéticas para usuários autentic | r | true | null |
| 29440 | public | schools | Enable insert for registration | a | null | true |
| 29299 | public | schools | Escolas visíveis para usuários autenticados | r | true | null |
| 29442 | public | schools | Schools can update own data | w | (auth.uid() = id) | (auth.uid() = id) |
| 29441 | public | schools | Schools can view own data | r | (auth.uid() = id) | null |
| policy_id | schema_name | table_name | policy_name | command | policy_using | policy_check |
| --------- | ----------- | ---------------------- | ------------------------------------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 29823 | auth | users | Validate user metadata | * | (((raw_user_meta_data ->> 'role'::text))::user_role IS NOT NULL) | null |
| 29615 | public | classes | Schools can create classes | a | null | (school_id = auth.uid()) |
| 29616 | public | classes | Schools can update their classes | w | (school_id = auth.uid()) | (school_id = auth.uid()) |
| 29614 | public | classes | Schools can view their classes | r | (school_id = auth.uid()) | null |
| 29301 | public | classes | Turmas visíveis para usuários autenticados | r | true | null |
| 113771 | public | essay_analyses | Alunos podem ver análises de suas próprias redações | r | (EXISTS ( SELECT 1
FROM student_essays
WHERE ((student_essays.id = essay_analyses.essay_id) AND (student_essays.student_id = auth.uid())))) | null |
| 113770 | public | essay_analyses | Edge Function pode inserir análises | a | null | (((auth.jwt() ->> 'role'::text) = 'service_role'::text) OR (EXISTS ( SELECT 1
FROM student_essays
WHERE ((student_essays.id = essay_analyses.essay_id) AND (student_essays.student_id = auth.uid()))))) |
| 113765 | public | essay_genres | Gêneros textuais visíveis para todos | r | (active = true) | null |
| 113764 | public | essay_types | Tipos de redação visíveis para todos | r | (active = true) | null |
| 65878 | public | interests | Students can delete their own interests | d | (auth.uid() = student_id) | null |
| 65876 | public | interests | Students can insert their own interests | a | null | (auth.uid() = student_id) |
| 65877 | public | interests | Students can update their own interests | w | (auth.uid() = student_id) | (auth.uid() = student_id) |
| 65875 | public | interests | Students can view their own interests | r | (auth.uid() = student_id) | null |
| 104599 | public | languages | Allow insert/update for admins only | * | ((auth.jwt() ->> 'role'::text) = 'admin'::text) | ((auth.jwt() ->> 'role'::text) = 'admin'::text) |
| 104598 | public | languages | Allow read access for all authenticated users | r | true | null |
| 79931 | public | phonics_categories | Permitir leitura de categorias fonéticas para usuários autent | r | true | null |
| 79932 | public | phonics_exercise_types | Permitir leitura de tipos de exercícios fonéticos para usuár | r | true | null |
| 79934 | public | phonics_exercise_words | Permitir leitura de relações exercício-palavra para usuário | r | true | null |
| 79930 | public | phonics_exercises | Permitir leitura de exercícios fonéticos para usuários auten | r | true | null |
| 79933 | public | phonics_words | Permitir leitura de palavras fonéticas para usuários autentic | r | true | null |
| 29440 | public | schools | Enable insert for registration | a | null | true |
| 29299 | public | schools | Escolas visíveis para usuários autenticados | r | true | null |
| 29442 | public | schools | Schools can update own data | w | (auth.uid() = id) | (auth.uid() = id) |
| 29441 | public | schools | Schools can view own data | r | (auth.uid() = id) | null |
| 29347 | public | stories | Alunos podem atualizar suas próprias histórias | w | (student_id IN ( SELECT students.id
FROM students
WHERE (students.email = auth.email()))) | null |
WHERE (students.email = auth.email()))) | null |
| 29346 | public | stories | Alunos podem criar suas próprias histórias | a | null | (student_id IN ( SELECT students.id
FROM students
WHERE (students.email = auth.email()))) |
WHERE (students.email = auth.email()))) |
| 36241 | public | stories | Estudantes podem ver suas próprias histórias | r | ((auth.uid() = student_id) AND (EXISTS ( SELECT 1
FROM story_themes
WHERE ((story_themes.id = stories.theme_id) AND (story_themes.active = true)))) AND (EXISTS ( SELECT 1
@ -34,60 +42,64 @@
FROM story_characters
WHERE ((story_characters.id = stories.character_id) AND (story_characters.active = true)))) AND (EXISTS ( SELECT 1
FROM story_settings
WHERE ((story_settings.id = stories.setting_id) AND (story_settings.active = true))))) | null |
| 29345 | public | stories | Histórias visíveis para usuários autenticados | r | true | null |
| 53384 | public | stories | Permitir deleção pelo dono | d | (auth.uid() = student_id) | null |
| 34952 | public | story_characters | Permitir leitura pública dos personagens | r | (active = true) | null |
| 53955 | public | story_exercise_words | Apenas sistema pode inserir | a | null | (auth.role() = 'service_role'::text) |
| 53954 | public | story_exercise_words | Leitura pública das palavras | r | true | null |
| 37664 | public | story_generations | Apenas service_role pode inserir metadados | a | null | true |
| 37663 | public | story_generations | Metadados são visíveis para todos | r | true | null |
| 37662 | public | story_pages | Apenas service_role pode inserir páginas | a | null | true |
| 37661 | public | story_pages | Páginas são visíveis para todos | r | true | null |
WHERE ((story_settings.id = stories.setting_id) AND (story_settings.active = true))))) | null |
| 29345 | public | stories | Histórias visíveis para usuários autenticados | r | true | null |
| 53384 | public | stories | Permitir deleção pelo dono | d | (auth.uid() = student_id) | null |
| 34952 | public | story_characters | Permitir leitura pública dos personagens | r | (active = true) | null |
| 53955 | public | story_exercise_words | Apenas sistema pode inserir | a | null | (auth.role() = 'service_role'::text) |
| 53954 | public | story_exercise_words | Leitura pública das palavras | r | true | null |
| 37664 | public | story_generations | Apenas service_role pode inserir metadados | a | null | true |
| 37663 | public | story_generations | Metadados são visíveis para todos | r | true | null |
| 37662 | public | story_pages | Apenas service_role pode inserir páginas | a | null | true |
| 37661 | public | story_pages | Páginas são visíveis para todos | r | true | null |
| 31560 | public | story_recordings | Escolas podem ver todas as gravações | r | (EXISTS ( SELECT 1
FROM students s
WHERE ((s.id = story_recordings.student_id) AND (s.school_id = auth.uid())))) | null |
| 30092 | public | story_recordings | Estudantes podem gravar áudios | a | null | (auth.uid() = student_id) |
| 31511 | public | story_recordings | Estudantes podem ver suas próprias gravações | r | (auth.uid() = student_id) | null |
WHERE ((s.id = story_recordings.student_id) AND (s.school_id = auth.uid())))) | null |
| 30092 | public | story_recordings | Estudantes podem gravar áudios | a | null | (auth.uid() = student_id) |
| 31511 | public | story_recordings | Estudantes podem ver suas próprias gravações | r | (auth.uid() = student_id) | null |
| 31558 | public | story_recordings | Professores podem ver gravações de seus alunos | r | (EXISTS ( SELECT 1
FROM (classes c
JOIN students s ON ((s.class_id = c.id)))
WHERE ((s.id = story_recordings.student_id) AND (c.teacher_id = auth.uid())))) | null |
| 29748 | public | story_recordings | Students can insert their own recordings | a | null | (auth.uid() = student_id) |
| 29749 | public | story_recordings | Students can view their own recordings | r | (auth.uid() = student_id) | null |
| 34953 | public | story_settings | Permitir leitura pública dos cenários | r | (active = true) | null |
| 34951 | public | story_subjects | Permitir leitura pública das disciplinas | r | (active = true) | null |
| 34950 | public | story_themes | Permitir leitura pública das categorias | r | (active = true) | null |
| 29302 | public | students | Alunos visíveis para usuários autenticados | r | true | null |
WHERE ((s.id = story_recordings.student_id) AND (c.teacher_id = auth.uid())))) | null |
| 29748 | public | story_recordings | Students can insert their own recordings | a | null | (auth.uid() = student_id) |
| 29749 | public | story_recordings | Students can view their own recordings | r | (auth.uid() = student_id) | null |
| 34953 | public | story_settings | Permitir leitura pública dos cenários | r | (active = true) | null |
| 34951 | public | story_subjects | Permitir leitura pública das disciplinas | r | (active = true) | null |
| 34950 | public | story_themes | Permitir leitura pública das categorias | r | (active = true) | null |
| 113768 | public | student_essays | Alunos podem atualizar suas próprias redações | w | (student_id = auth.uid()) | (student_id = auth.uid()) |
| 113767 | public | student_essays | Alunos podem criar suas próprias redações | a | null | (student_id = auth.uid()) |
| 113769 | public | student_essays | Alunos podem deletar suas próprias redações | d | (student_id = auth.uid()) | null |
| 113766 | public | student_essays | Alunos podem ver suas próprias redações | r | (student_id = auth.uid()) | null |
| 29302 | public | students | Alunos visíveis para usuários autenticados | r | true | null |
| 29638 | public | students | Escolas podem inserir seus próprios alunos | a | null | (auth.uid() IN ( SELECT schools.id
FROM schools
WHERE (schools.id = students.school_id))) |
WHERE (schools.id = students.school_id))) |
| 29639 | public | students | Escolas podem ver seus próprios alunos | r | (auth.uid() IN ( SELECT schools.id
FROM schools
WHERE (schools.id = students.school_id))) | null |
| 29584 | public | students | Schools can view their students | r | (school_id = auth.uid()) | null |
WHERE (schools.id = students.school_id))) | null |
| 29584 | public | students | Schools can view their students | r | (school_id = auth.uid()) | null |
| 29511 | public | teacher_invites | Schools can invite teachers | a | null | (school_id IN ( SELECT schools.id
FROM schools
WHERE (schools.id = auth.uid()))) |
| 29300 | public | teachers | Professores visíveis para usuários autenticados | r | true | null |
WHERE (schools.id = auth.uid()))) |
| 29300 | public | teachers | Professores visíveis para usuários autenticados | r | true | null |
| 29510 | public | teachers | Schools can view their teachers | r | (school_id IN ( SELECT schools.id
FROM schools
WHERE (schools.id = auth.uid()))) | null |
| 29509 | public | teachers | Teachers can view own data | r | (auth.uid() = id) | null |
| 29717 | storage | objects | Anyone can read recordings | r | (bucket_id = 'recordings'::text) | null |
| 30136 | storage | objects | Estudantes podem fazer upload de áudios | a | null | ((bucket_id = 'recordings'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])) |
| 75352 | storage | objects | Imagens são publicamente acessíveis | r | (bucket_id = 'story-images'::text) | null |
| 43940 | storage | objects | Permitir acesso da Edge Function | r | ((bucket_id = 'recordings'::text) AND ((auth.jwt() ->> 'role'::text) = 'service_role'::text)) | null |
| 52098 | storage | objects | Permitir acesso público para leitura | r | (bucket_id = 'recordings'::text) | null |
| 37570 | storage | objects | Permitir acesso público para leitura de imagens de histórias | r | (bucket_id = 'story-images'::text) | null |
| 37573 | storage | objects | Permitir delete pela edge function | d | (bucket_id = 'story-images'::text) | null |
WHERE (schools.id = auth.uid()))) | null |
| 29509 | public | teachers | Teachers can view own data | r | (auth.uid() = id) | null |
| 29717 | storage | objects | Anyone can read recordings | r | (bucket_id = 'recordings'::text) | null |
| 30136 | storage | objects | Estudantes podem fazer upload de áudios | a | null | ((bucket_id = 'recordings'::text) AND ((auth.uid())::text = (storage.foldername(name))[1])) |
| 75352 | storage | objects | Imagens são publicamente acessíveis | r | (bucket_id = 'story-images'::text) | null |
| 43940 | storage | objects | Permitir acesso da Edge Function | r | ((bucket_id = 'recordings'::text) AND ((auth.jwt() ->> 'role'::text) = 'service_role'::text)) | null |
| 52098 | storage | objects | Permitir acesso público para leitura | r | (bucket_id = 'recordings'::text) | null |
| 37570 | storage | objects | Permitir acesso público para leitura de imagens de histórias | r | (bucket_id = 'story-images'::text) | null |
| 37573 | storage | objects | Permitir delete pela edge function | d | (bucket_id = 'story-images'::text) | null |
| 53468 | storage | objects | Permitir deleção de imagens pelo dono da história | d | ((bucket_id = 'story-images'::text) AND (EXISTS ( SELECT 1
FROM stories s
WHERE ((s.id = ((storage.foldername(objects.name))[1])::uuid) AND (s.student_id = auth.uid()))))) | null |
| 53426 | storage | objects | Permitir deleção pelo dono do arquivo | d | ((bucket_id = 'recordings'::text) AND ((storage.foldername(name))[1] = (auth.uid())::text)) | null |
| 52099 | storage | objects | Permitir download público | r | (bucket_id = 'recordings'::text) | null |
| 37572 | storage | objects | Permitir update pela edge function | w | (bucket_id = 'story-images'::text) | null |
| 43939 | storage | objects | Permitir upload de áudios autenticado | a | null | ((bucket_id = 'recordings'::text) AND (auth.role() = 'authenticated'::text)) |
| 37571 | storage | objects | Permitir upload pela edge function | a | null | (bucket_id = 'story-images'::text) |
| 29716 | storage | objects | Students can upload their recordings | a | null | ((bucket_id = 'recordings'::text) AND (auth.role() = 'student'::text)) |
| 74045 | storage | objects | Áudios públicos | r | (bucket_id = 'phonics-audio'::text) | null |
WHERE ((s.id = ((storage.foldername(objects.name))[1])::uuid) AND (s.student_id = auth.uid()))))) | null |
| 53426 | storage | objects | Permitir deleção pelo dono do arquivo | d | ((bucket_id = 'recordings'::text) AND ((storage.foldername(name))[1] = (auth.uid())::text)) | null |
| 52099 | storage | objects | Permitir download público | r | (bucket_id = 'recordings'::text) | null |
| 37572 | storage | objects | Permitir update pela edge function | w | (bucket_id = 'story-images'::text) | null |
| 43939 | storage | objects | Permitir upload de áudios autenticado | a | null | ((bucket_id = 'recordings'::text) AND (auth.role() = 'authenticated'::text)) |
| 37571 | storage | objects | Permitir upload pela edge function | a | null | (bucket_id = 'story-images'::text) |
| 29716 | storage | objects | Students can upload their recordings | a | null | ((bucket_id = 'recordings'::text) AND (auth.role() = 'student'::text)) |
| 74045 | storage | objects | Áudios públicos | r | (bucket_id = 'phonics-audio'::text) | null |

View File

@ -0,0 +1,21 @@
const ALLOWED_ORIGINS = [
'http://localhost:5173', // Vite dev server
'http://localhost:9999', // Supabase Edge Functions local
'http://localhost', // Vite dev server
'http://localhost:3000', // Caso use outro port
'https://leiturama.ai', // Produção
'https://leiturama.netlify.app' // Staging
];
export function getCorsHeaders(req: Request) {
const origin = req.headers.get('origin') || '';
return {
'Cross-Origin-Resource-Policy': 'cross-origin',
'Access-Control-Allow-Origin': ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS, PUT, DELETE',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Access-Control-Max-Age': '86400', // 24 horas
'Cross-Origin-Embedder-Policy': 'credentialless'
};
}

View File

@ -0,0 +1,461 @@
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
import { getCorsHeaders } from '../_shared/cors.ts'
import { OpenAI } from "https://deno.land/x/openai@v4.24.0/mod.ts";
interface EssayAnalysisRequest {
essay_id: string;
content: string;
type_id: string;
genre_id: string;
}
interface EssayAnalysisResponse {
overall_score: number;
suggestions: string;
feedback: {
structure: string;
content: string;
language: string;
};
strengths: string[];
improvements: string[];
criteria_scores: {
adequacy: number;
coherence: number;
cohesion: number;
vocabulary: number;
grammar: number;
};
}
interface EssayType {
id: string;
slug: string;
title: string;
description: string;
}
interface EssayGenre {
id: string;
type_id: string;
slug: string;
title: string;
description: string;
requirements: {
min_words: number;
max_words: number;
required_elements: string[];
};
}
serve(async (req) => {
console.log(`[${new Date().toISOString()}] Nova requisição recebida`)
// Handle CORS
if (req.method === 'OPTIONS') {
console.log('Requisição OPTIONS - CORS preflight')
return new Response('ok', { headers: getCorsHeaders(req) })
}
try {
// Criar cliente Supabase
console.log('Inicializando cliente Supabase...')
const supabaseUrl = Deno.env.get('SUPABASE_URL')
const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('Variáveis de ambiente do Supabase não configuradas')
}
const supabaseClient = createClient(supabaseUrl, supabaseServiceKey, {
auth: {
persistSession: false,
autoRefreshToken: false,
}
})
// Criar cliente OpenAI
console.log('Inicializando cliente OpenAI...')
const openaiKey = Deno.env.get('OPENAI_API_KEY')
if (!openaiKey) {
throw new Error('OPENAI_API_KEY não configurada')
}
const openai = new OpenAI({
apiKey: openaiKey,
});
// Obter dados da requisição
console.log('Obtendo dados da requisição...')
const requestData = await req.json()
console.log('Dados recebidos:', JSON.stringify(requestData, null, 2))
const { essay_id, content, type_id, genre_id }: EssayAnalysisRequest = requestData
// Validar dados obrigatórios
if (!essay_id || !content || !type_id || !genre_id) {
console.error('Dados obrigatórios faltando:', { essay_id, content: !!content, type_id, genre_id })
return new Response(
JSON.stringify({
error: 'Dados obrigatórios não fornecidos',
details: {
essay_id: !essay_id,
content: !content,
type_id: !type_id,
genre_id: !genre_id
}
}),
{ status: 400, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
// Buscar informações do tipo e gênero
console.log('Buscando informações do tipo e gênero...')
const { data: typeData, error: typeError } = await supabaseClient
.from('essay_types')
.select('*')
.eq('id', type_id)
.single()
if (typeError) {
console.error('Erro ao buscar tipo:', typeError)
}
const { data: genreData, error: genreError } = await supabaseClient
.from('essay_genres')
.select('*')
.eq('id', genre_id)
.single()
if (genreError) {
console.error('Erro ao buscar gênero:', genreError)
}
if (typeError || genreError || !typeData || !genreData) {
return new Response(
JSON.stringify({
error: 'Tipo ou gênero não encontrado',
details: {
typeError: typeError?.message,
genreError: genreError?.message
}
}),
{ status: 404, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
const essayType: EssayType = typeData
const essayGenre: EssayGenre = genreData
console.log('Tipo e gênero encontrados:', {
type: essayType.title,
genre: essayGenre.title
})
// Construir prompt para a análise
console.log('Construindo prompt para análise...')
const prompt = `Você é um professor especialista em análise de textos. Analise a redação a seguir considerando que é do tipo "${essayType.title}" e gênero "${essayGenre.title}".
Requisitos específicos do gênero:
- Mínimo de palavras: ${essayGenre.requirements.min_words}
- Máximo de palavras: ${essayGenre.requirements.max_words}
- Elementos obrigatórios: ${essayGenre.requirements.required_elements.join(', ')}
Texto para análise:
${content}
Forneça uma análise detalhada considerando:
1. Adequação ao tipo e gênero textual
2. Coerência e coesão textual
3. Vocabulário e linguagem
4. Gramática e ortografia
5. Elementos obrigatórios do gênero
6. Pontos fortes
7. Pontos a melhorar
8. Sugestões específicas para aprimoramento
Responda em formato JSON seguindo exatamente esta estrutura:
{
"overall_score": number, // 0 a 100
"suggestions": string, // Sugestões específicas
"feedback": {
"structure": string, // Feedback sobre estrutura e organização
"content": string, // Feedback sobre conteúdo e ideias
"language": string // Feedback sobre linguagem e gramática
},
"strengths": string[], // Lista de pontos fortes
"improvements": string[], // Lista de pontos a melhorar
"criteria_scores": {
"adequacy": number, // 0 a 100 - Adequação ao tema/gênero
"coherence": number, // 0 a 100 - Coerência textual
"cohesion": number, // 0 a 100 - Coesão
"vocabulary": number, // 0 a 100 - Vocabulário
"grammar": number // 0 a 100 - Gramática e ortografia
}
}`
// Realizar análise com OpenAI
console.log('Enviando requisição para OpenAI...')
try {
const completion = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{
role: "system",
content: "Você é um professor especialista em análise de textos, com vasta experiência em avaliação de redações."
},
{
role: "user",
content: prompt
}
],
response_format: {
type: "json_schema",
json_schema: {
name: "essay_analysis",
strict: true,
description: "Análise detalhada da redação",
schema: {
type: "object",
additionalProperties: false,
required: ["overall_score", "suggestions", "feedback", "strengths", "improvements", "criteria_scores"],
properties: {
overall_score: {
type: "number",
description: "Pontuação geral da redação (0-100)"
},
suggestions: {
type: "string",
description: "Sugestões específicas para aprimoramento"
},
feedback: {
type: "object",
additionalProperties: false,
required: ["structure", "content", "language"],
properties: {
structure: {
type: "string",
description: "Feedback sobre estrutura e organização"
},
content: {
type: "string",
description: "Feedback sobre conteúdo e ideias"
},
language: {
type: "string",
description: "Feedback sobre linguagem e gramática"
}
}
},
strengths: {
type: "array",
items: {
type: "string",
description: "Ponto forte da redação"
},
description: "Lista de pontos fortes da redação"
},
improvements: {
type: "array",
items: {
type: "string",
description: "Ponto a melhorar na redação"
},
description: "Lista de pontos a melhorar na redação"
},
criteria_scores: {
type: "object",
additionalProperties: false,
required: ["adequacy", "coherence", "cohesion", "vocabulary", "grammar"],
properties: {
adequacy: {
type: "number",
description: "Adequação ao tema/gênero (0-100)"
},
coherence: {
type: "number",
description: "Coerência textual (0-100)"
},
cohesion: {
type: "number",
description: "Coesão textual (0-100)"
},
vocabulary: {
type: "number",
description: "Vocabulário (0-100)"
},
grammar: {
type: "number",
description: "Gramática e ortografia (0-100)"
}
}
}
}
}
}
}
});
console.log('Resposta recebida da OpenAI')
const analysis: EssayAnalysisResponse = JSON.parse(completion.choices[0].message.content)
console.log('Análise gerada:', JSON.stringify(analysis, null, 2))
// Salvar análise no banco
console.log('Salvando análise no banco...')
// Primeiro, criar a análise principal
const { data: analysisData, error: analysisError } = await supabaseClient
.from('essay_analyses')
.insert({
essay_id,
overall_score: analysis.overall_score,
suggestions: analysis.suggestions
})
.select()
.single()
if (analysisError) {
console.error('Erro ao salvar análise principal:', analysisError)
return new Response(
JSON.stringify({
error: 'Erro ao salvar análise',
details: analysisError.message
}),
{ status: 500, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
// Salvar feedback
const { error: feedbackError } = await supabaseClient
.from('essay_analysis_feedback')
.insert({
analysis_id: analysisData.id,
structure_feedback: analysis.feedback.structure,
content_feedback: analysis.feedback.content,
language_feedback: analysis.feedback.language
})
if (feedbackError) {
console.error('Erro ao salvar feedback:', feedbackError)
return new Response(
JSON.stringify({
error: 'Erro ao salvar feedback',
details: feedbackError.message
}),
{ status: 500, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
// Salvar pontos fortes
const strengths = analysis.strengths.map(strength => ({
analysis_id: analysisData.id,
strength
}))
const { error: strengthsError } = await supabaseClient
.from('essay_analysis_strengths')
.insert(strengths)
if (strengthsError) {
console.error('Erro ao salvar pontos fortes:', strengthsError)
return new Response(
JSON.stringify({
error: 'Erro ao salvar pontos fortes',
details: strengthsError.message
}),
{ status: 500, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
// Salvar pontos a melhorar
const improvements = analysis.improvements.map(improvement => ({
analysis_id: analysisData.id,
improvement
}))
const { error: improvementsError } = await supabaseClient
.from('essay_analysis_improvements')
.insert(improvements)
if (improvementsError) {
console.error('Erro ao salvar pontos a melhorar:', improvementsError)
return new Response(
JSON.stringify({
error: 'Erro ao salvar pontos a melhorar',
details: improvementsError.message
}),
{ status: 500, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
// Salvar notas por critério
const { error: scoresError } = await supabaseClient
.from('essay_analysis_scores')
.insert({
analysis_id: analysisData.id,
adequacy: analysis.criteria_scores.adequacy,
coherence: analysis.criteria_scores.coherence,
cohesion: analysis.criteria_scores.cohesion,
vocabulary: analysis.criteria_scores.vocabulary,
grammar: analysis.criteria_scores.grammar
})
if (scoresError) {
console.error('Erro ao salvar notas:', scoresError)
return new Response(
JSON.stringify({
error: 'Erro ao salvar notas',
details: scoresError.message
}),
{ status: 500, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
// Atualizar status da redação
console.log('Atualizando status da redação...')
const { error: updateError } = await supabaseClient
.from('student_essays')
.update({ status: 'analyzed' })
.eq('id', essay_id)
if (updateError) {
console.error('Erro ao atualizar status:', updateError)
return new Response(
JSON.stringify({
error: 'Erro ao atualizar status da redação',
details: updateError.message
}),
{ status: 500, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
console.log('Análise concluída com sucesso')
// Retornar análise
return new Response(
JSON.stringify(analysis),
{ headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
} catch (openaiError) {
console.error('Erro na chamada da OpenAI:', openaiError)
return new Response(
JSON.stringify({
error: 'Erro ao gerar análise',
details: openaiError.message
}),
{ status: 500, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
} catch (error) {
console.error('Erro geral na função:', error)
return new Response(
JSON.stringify({
error: 'Erro interno do servidor',
details: error.message
}),
{ status: 500, headers: { ...getCorsHeaders(req), 'Content-Type': 'application/json' } }
)
}
})

View File

@ -0,0 +1,229 @@
-- Criação do sistema de redações (Essays)
-- Tipos de texto (Narrativo, Dissertativo, etc)
CREATE TABLE public.essay_types (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL,
active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Gêneros textuais (Artigo de opinião, Carta argumentativa, etc)
CREATE TABLE public.essay_genres (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
type_id UUID NOT NULL REFERENCES public.essay_types(id),
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
description TEXT NOT NULL,
icon TEXT NOT NULL,
requirements JSONB NOT NULL DEFAULT '{}',
active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Redações dos alunos
CREATE TABLE public.student_essays (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
student_id UUID NOT NULL REFERENCES public.students(id),
type_id UUID NOT NULL REFERENCES public.essay_types(id),
genre_id UUID NOT NULL REFERENCES public.essay_genres(id),
title TEXT NOT NULL,
content TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL,
CONSTRAINT status_check CHECK (status IN ('draft', 'submitted', 'analyzed'))
);
-- Análises das redações
CREATE TABLE public.essay_analyses (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
essay_id UUID NOT NULL REFERENCES public.student_essays(id),
overall_score INTEGER NOT NULL CHECK (overall_score >= 0 AND overall_score <= 100),
suggestions TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Feedback das análises
CREATE TABLE public.essay_analysis_feedback (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
analysis_id UUID NOT NULL REFERENCES public.essay_analyses(id) ON DELETE CASCADE,
structure_feedback TEXT NOT NULL,
content_feedback TEXT NOT NULL,
language_feedback TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Pontos fortes das análises
CREATE TABLE public.essay_analysis_strengths (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
analysis_id UUID NOT NULL REFERENCES public.essay_analyses(id) ON DELETE CASCADE,
strength TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Pontos a melhorar das análises
CREATE TABLE public.essay_analysis_improvements (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
analysis_id UUID NOT NULL REFERENCES public.essay_analyses(id) ON DELETE CASCADE,
improvement TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Notas por critério das análises
CREATE TABLE public.essay_analysis_scores (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
analysis_id UUID NOT NULL REFERENCES public.essay_analyses(id) ON DELETE CASCADE,
adequacy INTEGER NOT NULL CHECK (adequacy >= 0 AND adequacy <= 100),
coherence INTEGER NOT NULL CHECK (coherence >= 0 AND coherence <= 100),
cohesion INTEGER NOT NULL CHECK (cohesion >= 0 AND cohesion <= 100),
vocabulary INTEGER NOT NULL CHECK (vocabulary >= 0 AND vocabulary <= 100),
grammar INTEGER NOT NULL CHECK (grammar >= 0 AND grammar <= 100),
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Índices para melhor performance
CREATE INDEX idx_student_essays_student_id ON public.student_essays(student_id);
CREATE INDEX idx_student_essays_status ON public.student_essays(status);
CREATE INDEX idx_essay_genres_type_id ON public.essay_genres(type_id);
CREATE INDEX idx_essay_analyses_essay_id ON public.essay_analyses(essay_id);
CREATE INDEX idx_essay_analysis_feedback_analysis_id ON public.essay_analysis_feedback(analysis_id);
CREATE INDEX idx_essay_analysis_strengths_analysis_id ON public.essay_analysis_strengths(analysis_id);
CREATE INDEX idx_essay_analysis_improvements_analysis_id ON public.essay_analysis_improvements(analysis_id);
CREATE INDEX idx_essay_analysis_scores_analysis_id ON public.essay_analysis_scores(analysis_id);
-- Triggers para updated_at
CREATE OR REPLACE FUNCTION public.handle_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ language 'plpgsql';
CREATE TRIGGER essay_types_updated_at
BEFORE UPDATE ON public.essay_types
FOR EACH ROW
EXECUTE PROCEDURE public.handle_updated_at();
CREATE TRIGGER essay_genres_updated_at
BEFORE UPDATE ON public.essay_genres
FOR EACH ROW
EXECUTE PROCEDURE public.handle_updated_at();
CREATE TRIGGER student_essays_updated_at
BEFORE UPDATE ON public.student_essays
FOR EACH ROW
EXECUTE PROCEDURE public.handle_updated_at();
-- Políticas RLS
ALTER TABLE public.essay_types ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.essay_genres ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.student_essays ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.essay_analyses ENABLE ROW LEVEL SECURITY;
-- Políticas para essay_types (visível para todos)
CREATE POLICY "Tipos de redação visíveis para todos"
ON public.essay_types FOR SELECT
USING (active = true);
-- Políticas para essay_genres (visível para todos)
CREATE POLICY "Gêneros textuais visíveis para todos"
ON public.essay_genres FOR SELECT
USING (active = true);
-- Políticas para student_essays
CREATE POLICY "Alunos podem ver suas próprias redações"
ON public.student_essays FOR SELECT
USING (student_id = auth.uid());
CREATE POLICY "Alunos podem criar suas próprias redações"
ON public.student_essays FOR INSERT
WITH CHECK (student_id = auth.uid());
CREATE POLICY "Alunos podem atualizar suas próprias redações"
ON public.student_essays FOR UPDATE
USING (student_id = auth.uid())
WITH CHECK (student_id = auth.uid());
CREATE POLICY "Alunos podem deletar suas próprias redações"
ON public.student_essays FOR DELETE
USING (student_id = auth.uid());
-- Políticas para essay_analyses
CREATE POLICY "Edge Function pode inserir análises"
ON public.essay_analyses FOR INSERT
WITH CHECK (
-- A função de análise roda com a service_role, então permitimos
(auth.jwt() ->> 'role' = 'service_role') OR
-- Permitir inserção se o essay_id pertence ao usuário atual
EXISTS (
SELECT 1
FROM public.student_essays
WHERE id = essay_id
AND student_id = auth.uid()
)
);
CREATE POLICY "Alunos podem ver análises de suas próprias redações"
ON public.essay_analyses FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.student_essays
WHERE id = essay_id
AND student_id = auth.uid()
)
);
-- Função para verificar se uma redação pertence ao aluno
CREATE OR REPLACE FUNCTION public.check_essay_ownership(essay_id UUID)
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1
FROM public.student_essays
WHERE id = essay_id
AND student_id = auth.uid()
);
END;
$$ language plpgsql security definer;
-- Comentários nas tabelas
COMMENT ON TABLE public.essay_types IS 'Tipos de texto (Narrativo, Dissertativo, etc)';
COMMENT ON TABLE public.essay_genres IS 'Gêneros textuais relacionados a cada tipo de texto';
COMMENT ON TABLE public.student_essays IS 'Redações escritas pelos alunos';
COMMENT ON TABLE public.essay_analyses IS 'Análises e feedbacks das redações dos alunos';
-- Dados iniciais
INSERT INTO public.essay_types (slug, title, description, icon) VALUES
('narrative', 'Narrativo', 'Textos que contam uma história com personagens, tempo e espaço definidos', '📖'),
('dissertation', 'Dissertativo', 'Textos que apresentam uma análise e discussão de um tema', '📝'),
('descriptive', 'Descritivo', 'Textos que descrevem detalhadamente um objeto, pessoa, lugar ou situação', '🎨');
INSERT INTO public.essay_genres (type_id, slug, title, description, icon, requirements) VALUES
((SELECT id FROM public.essay_types WHERE slug = 'dissertation'),
'opinion-article',
'Artigo de Opinião',
'Texto que apresenta um ponto de vista sobre um tema atual',
'📰',
'{"min_words": 300, "max_words": 600, "required_sections": ["introduction", "development", "conclusion"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'narrative'),
'short-story',
'Conto',
'História curta com poucos personagens e um único conflito',
'📚',
'{"min_words": 200, "max_words": 1000, "required_elements": ["characters", "setting", "conflict", "resolution"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'descriptive'),
'character-description',
'Descrição de Personagem',
'Texto que descreve características físicas e psicológicas de um personagem',
'👤',
'{"min_words": 150, "max_words": 400, "required_aspects": ["physical", "psychological", "habits"]}'
);

View File

@ -0,0 +1,37 @@
-- Remover políticas
DROP POLICY IF EXISTS "Edge Function pode inserir análises" ON public.essay_analyses;
DROP POLICY IF EXISTS "Alunos podem ver análises de suas próprias redações" ON public.essay_analyses;
DROP POLICY IF EXISTS "Alunos podem deletar suas próprias redações" ON public.student_essays;
DROP POLICY IF EXISTS "Alunos podem atualizar suas próprias redações" ON public.student_essays;
DROP POLICY IF EXISTS "Alunos podem criar suas próprias redações" ON public.student_essays;
DROP POLICY IF EXISTS "Alunos podem ver suas próprias redações" ON public.student_essays;
DROP POLICY IF EXISTS "Gêneros textuais visíveis para todos" ON public.essay_genres;
DROP POLICY IF EXISTS "Tipos de redação visíveis para todos" ON public.essay_types;
-- Remover função de verificação de propriedade
DROP FUNCTION IF EXISTS public.check_essay_ownership(UUID);
-- Remover triggers
DROP TRIGGER IF EXISTS student_essays_updated_at ON public.student_essays;
DROP TRIGGER IF EXISTS essay_genres_updated_at ON public.essay_genres;
DROP TRIGGER IF EXISTS essay_types_updated_at ON public.essay_types;
-- Remover índices
DROP INDEX IF EXISTS public.idx_essay_analysis_scores_analysis_id;
DROP INDEX IF EXISTS public.idx_essay_analysis_improvements_analysis_id;
DROP INDEX IF EXISTS public.idx_essay_analysis_strengths_analysis_id;
DROP INDEX IF EXISTS public.idx_essay_analysis_feedback_analysis_id;
DROP INDEX IF EXISTS public.idx_essay_analyses_essay_id;
DROP INDEX IF EXISTS public.idx_essay_genres_type_id;
DROP INDEX IF EXISTS public.idx_student_essays_status;
DROP INDEX IF EXISTS public.idx_student_essays_student_id;
-- Remover tabelas
DROP TABLE IF EXISTS public.essay_analysis_scores;
DROP TABLE IF EXISTS public.essay_analysis_improvements;
DROP TABLE IF EXISTS public.essay_analysis_strengths;
DROP TABLE IF EXISTS public.essay_analysis_feedback;
DROP TABLE IF EXISTS public.essay_analyses;
DROP TABLE IF EXISTS public.student_essays;
DROP TABLE IF EXISTS public.essay_genres;
DROP TABLE IF EXISTS public.essay_types;

View File

@ -0,0 +1,141 @@
-- Inserir tipos textuais
INSERT INTO public.essay_types (slug, title, description, icon) VALUES
('narrative', 'Narrativo', 'Textos que narram acontecimentos reais ou fictícios, com personagens, tempo e espaço definidos', '📖'),
('descriptive', 'Descrição', 'Textos que descrevem detalhadamente características de algo ou alguém', '🎨'),
('expository', 'Expositivo', 'Textos que explicam e informam sobre um determinado assunto', '📚'),
('argumentative', 'Argumentativo', 'Textos que defendem uma ideia ou ponto de vista com argumentos', '⚖️'),
('injunctive', 'Injuntivo', 'Textos que orientam ou instruem sobre como realizar algo', '📝');
-- Inserir gêneros textuais para tipo Narrativo
INSERT INTO public.essay_genres (type_id, slug, title, description, icon, requirements) VALUES
((SELECT id FROM public.essay_types WHERE slug = 'narrative'),
'short-story',
'Conto',
'História curta com poucos personagens e um único conflito',
'📚',
'{"min_words": 200, "max_words": 1000, "required_elements": ["personagens", "tempo", "espaço", "conflito", "resolução"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'narrative'),
'chronicle',
'Crônica',
'Narrativa curta que retrata situações do cotidiano',
'📰',
'{"min_words": 150, "max_words": 800, "required_elements": ["situação_cotidiana", "reflexão", "linguagem_informal"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'narrative'),
'novel',
'Romance',
'História longa com desenvolvimento aprofundado de personagens e tramas',
'📖',
'{"min_words": 1000, "max_words": 3000, "required_elements": ["personagens_principais", "personagens_secundários", "múltiplos_conflitos", "desenvolvimento_completo"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'narrative'),
'news',
'Notícia',
'Relato de fatos reais de forma objetiva',
'📰',
'{"min_words": 150, "max_words": 500, "required_elements": ["lead", "corpo_da_notícia", "objetividade"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'narrative'),
'biography',
'Biografia/Autobiografia',
'Relato da vida de uma pessoa',
'👤',
'{"min_words": 300, "max_words": 1000, "required_elements": ["dados_pessoais", "acontecimentos_importantes", "ordem_cronológica"]}'
);
-- Inserir gêneros textuais para tipo Descritivo
INSERT INTO public.essay_genres (type_id, slug, title, description, icon, requirements) VALUES
((SELECT id FROM public.essay_types WHERE slug = 'descriptive'),
'menu',
'Cardápio',
'Descrição detalhada de pratos e bebidas',
'🍽️',
'{"min_words": 50, "max_words": 200, "required_elements": ["nome_do_prato", "ingredientes", "preço"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'descriptive'),
'descriptive-report',
'Relato descritivo',
'Descrição detalhada de um objeto, pessoa ou ambiente',
'🔍',
'{"min_words": 200, "max_words": 600, "required_elements": ["características_físicas", "sensações", "detalhes_específicos"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'descriptive'),
'reportage',
'Reportagem',
'Descrição aprofundada de um fato ou tema',
'📰',
'{"min_words": 400, "max_words": 1000, "required_elements": ["contextualização", "detalhamento", "fontes"]}'
);
-- Inserir gêneros textuais para tipo Expositivo
INSERT INTO public.essay_genres (type_id, slug, title, description, icon, requirements) VALUES
((SELECT id FROM public.essay_types WHERE slug = 'expository'),
'didactic-text',
'Texto didático',
'Explicação clara de um conteúdo para fins educacionais',
'📚',
'{"min_words": 200, "max_words": 800, "required_elements": ["definição", "exemplos", "explicação"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'expository'),
'lecture',
'Palestra',
'Apresentação expositiva sobre um tema específico',
'🎤',
'{"min_words": 500, "max_words": 1500, "required_elements": ["introdução", "desenvolvimento", "conclusão", "exemplos_práticos"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'expository'),
'reportage-exp',
'Reportagem',
'Texto informativo sobre um tema ou acontecimento',
'📰',
'{"min_words": 400, "max_words": 1000, "required_elements": ["contextualização", "dados", "fontes"]}'
);
-- Inserir gêneros textuais para tipo Argumentativo
INSERT INTO public.essay_genres (type_id, slug, title, description, icon, requirements) VALUES
((SELECT id FROM public.essay_types WHERE slug = 'argumentative'),
'open-letter',
'Carta aberta',
'Texto que expõe publicamente argumentos sobre uma questão',
'✉️',
'{"min_words": 300, "max_words": 800, "required_elements": ["destinatário", "argumentos", "pedido_ou_reivindicação"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'argumentative'),
'thesis',
'Tese',
'Texto que defende uma ideia central com argumentos',
'📑',
'{"min_words": 500, "max_words": 1500, "required_elements": ["hipótese", "argumentos", "comprovação"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'argumentative'),
'scientific-article',
'Artigo científico',
'Texto que apresenta resultados de uma pesquisa',
'🔬',
'{"min_words": 1000, "max_words": 3000, "required_elements": ["introdução", "metodologia", "resultados", "conclusão"]}'
);
-- Inserir gêneros textuais para tipo Injuntivo
INSERT INTO public.essay_genres (type_id, slug, title, description, icon, requirements) VALUES
((SELECT id FROM public.essay_types WHERE slug = 'injunctive'),
'instruction-manual',
'Manual de instrução',
'Texto que orienta sobre o uso de um produto',
'📖',
'{"min_words": 100, "max_words": 500, "required_elements": ["passo_a_passo", "advertências", "ilustrações"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'injunctive'),
'advertisement',
'Propaganda',
'Texto que persuade o leitor a uma ação',
'📢',
'{"min_words": 50, "max_words": 200, "required_elements": ["slogan", "argumentos_persuasivos", "chamada_para_ação"]}'
),
((SELECT id FROM public.essay_types WHERE slug = 'injunctive'),
'recipe',
'Receita',
'Texto que instrui o preparo de um prato',
'👩‍🍳',
'{"min_words": 100, "max_words": 400, "required_elements": ["ingredientes", "modo_de_preparo", "tempo_de_preparo", "rendimento"]}'
);

View File

@ -0,0 +1,94 @@
-- Corrige as políticas de segurança para análises de redação
DROP POLICY IF EXISTS "Edge Function pode inserir análises" ON public.essay_analyses;
DROP POLICY IF EXISTS "Alunos podem ver análises de suas próprias redações" ON public.essay_analyses;
-- Política para permitir que a service_role insira análises
CREATE POLICY "Service role pode inserir análises"
ON public.essay_analyses FOR INSERT
WITH CHECK (auth.jwt() ->> 'role' = 'service_role');
-- Política para permitir que alunos vejam suas análises
CREATE POLICY "Alunos podem ver suas análises"
ON public.essay_analyses FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.student_essays
WHERE id = essay_id
AND student_id = auth.uid()
)
);
-- Habilita RLS para tabelas relacionadas
ALTER TABLE public.essay_analysis_feedback ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.essay_analysis_strengths ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.essay_analysis_improvements ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.essay_analysis_scores ENABLE ROW LEVEL SECURITY;
-- Políticas para feedback
CREATE POLICY "Service role pode inserir feedback"
ON public.essay_analysis_feedback FOR INSERT
WITH CHECK (auth.jwt() ->> 'role' = 'service_role');
CREATE POLICY "Alunos podem ver feedback de suas análises"
ON public.essay_analysis_feedback FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.essay_analyses a
JOIN public.student_essays e ON a.essay_id = e.id
WHERE a.id = analysis_id
AND e.student_id = auth.uid()
)
);
-- Políticas para pontos fortes
CREATE POLICY "Service role pode inserir pontos fortes"
ON public.essay_analysis_strengths FOR INSERT
WITH CHECK (auth.jwt() ->> 'role' = 'service_role');
CREATE POLICY "Alunos podem ver pontos fortes de suas análises"
ON public.essay_analysis_strengths FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.essay_analyses a
JOIN public.student_essays e ON a.essay_id = e.id
WHERE a.id = analysis_id
AND e.student_id = auth.uid()
)
);
-- Políticas para pontos a melhorar
CREATE POLICY "Service role pode inserir pontos a melhorar"
ON public.essay_analysis_improvements FOR INSERT
WITH CHECK (auth.jwt() ->> 'role' = 'service_role');
CREATE POLICY "Alunos podem ver pontos a melhorar de suas análises"
ON public.essay_analysis_improvements FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.essay_analyses a
JOIN public.student_essays e ON a.essay_id = e.id
WHERE a.id = analysis_id
AND e.student_id = auth.uid()
)
);
-- Políticas para notas por critério
CREATE POLICY "Service role pode inserir notas"
ON public.essay_analysis_scores FOR INSERT
WITH CHECK (auth.jwt() ->> 'role' = 'service_role');
CREATE POLICY "Alunos podem ver notas de suas análises"
ON public.essay_analysis_scores FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.essay_analyses a
JOIN public.student_essays e ON a.essay_id = e.id
WHERE a.id = analysis_id
AND e.student_id = auth.uid()
)
);

View File

@ -0,0 +1,165 @@
-- Primeiro, vamos criar as novas tabelas normalizadas
CREATE TABLE public.essay_analysis_feedback (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
analysis_id UUID NOT NULL,
structure_feedback TEXT NOT NULL,
content_feedback TEXT NOT NULL,
language_feedback TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
CREATE TABLE public.essay_analysis_strengths (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
analysis_id UUID NOT NULL,
strength TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
CREATE TABLE public.essay_analysis_improvements (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
analysis_id UUID NOT NULL,
improvement TEXT NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
CREATE TABLE public.essay_analysis_scores (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
analysis_id UUID NOT NULL,
adequacy INTEGER NOT NULL CHECK (adequacy >= 0 AND adequacy <= 100),
coherence INTEGER NOT NULL CHECK (coherence >= 0 AND coherence <= 100),
cohesion INTEGER NOT NULL CHECK (cohesion >= 0 AND cohesion <= 100),
vocabulary INTEGER NOT NULL CHECK (vocabulary >= 0 AND vocabulary <= 100),
grammar INTEGER NOT NULL CHECK (grammar >= 0 AND grammar <= 100),
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Criar nova versão da tabela essay_analyses sem os campos JSONB e arrays
CREATE TABLE public.essay_analyses_new (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
essay_id UUID NOT NULL REFERENCES public.student_essays(id),
overall_score INTEGER NOT NULL CHECK (overall_score >= 0 AND overall_score <= 100),
suggestions TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Migrar dados da tabela antiga para as novas tabelas
INSERT INTO public.essay_analyses_new (id, essay_id, overall_score, suggestions, created_at)
SELECT id, essay_id, overall_score, suggestions, created_at
FROM public.essay_analyses;
-- Adicionar chaves estrangeiras nas tabelas de normalização
ALTER TABLE public.essay_analysis_feedback
ADD CONSTRAINT fk_analysis_feedback
FOREIGN KEY (analysis_id)
REFERENCES public.essay_analyses_new(id)
ON DELETE CASCADE;
ALTER TABLE public.essay_analysis_strengths
ADD CONSTRAINT fk_analysis_strengths
FOREIGN KEY (analysis_id)
REFERENCES public.essay_analyses_new(id)
ON DELETE CASCADE;
ALTER TABLE public.essay_analysis_improvements
ADD CONSTRAINT fk_analysis_improvements
FOREIGN KEY (analysis_id)
REFERENCES public.essay_analyses_new(id)
ON DELETE CASCADE;
ALTER TABLE public.essay_analysis_scores
ADD CONSTRAINT fk_analysis_scores
FOREIGN KEY (analysis_id)
REFERENCES public.essay_analyses_new(id)
ON DELETE CASCADE;
-- Criar índices para melhor performance
CREATE INDEX idx_essay_analysis_feedback_analysis_id ON public.essay_analysis_feedback(analysis_id);
CREATE INDEX idx_essay_analysis_strengths_analysis_id ON public.essay_analysis_strengths(analysis_id);
CREATE INDEX idx_essay_analysis_improvements_analysis_id ON public.essay_analysis_improvements(analysis_id);
CREATE INDEX idx_essay_analysis_scores_analysis_id ON public.essay_analysis_scores(analysis_id);
-- Aplicar políticas RLS nas novas tabelas
ALTER TABLE public.essay_analyses_new ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.essay_analysis_feedback ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.essay_analysis_strengths ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.essay_analysis_improvements ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.essay_analysis_scores ENABLE ROW LEVEL SECURITY;
-- Políticas para essay_analyses_new
CREATE POLICY "Edge Function pode inserir análises"
ON public.essay_analyses_new FOR INSERT
WITH CHECK (
(auth.jwt() ->> 'role' = 'service_role') OR
EXISTS (
SELECT 1
FROM public.student_essays
WHERE id = essay_id
AND student_id = auth.uid()
)
);
CREATE POLICY "Alunos podem ver análises de suas próprias redações"
ON public.essay_analyses_new FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.student_essays
WHERE id = essay_id
AND student_id = auth.uid()
)
);
-- Políticas para tabelas relacionadas
CREATE POLICY "Acesso vinculado à análise principal - feedback"
ON public.essay_analysis_feedback FOR ALL
USING (
EXISTS (
SELECT 1
FROM public.essay_analyses_new a
JOIN public.student_essays e ON e.id = a.essay_id
WHERE a.id = analysis_id
AND e.student_id = auth.uid()
)
);
CREATE POLICY "Acesso vinculado à análise principal - strengths"
ON public.essay_analysis_strengths FOR ALL
USING (
EXISTS (
SELECT 1
FROM public.essay_analyses_new a
JOIN public.student_essays e ON e.id = a.essay_id
WHERE a.id = analysis_id
AND e.student_id = auth.uid()
)
);
CREATE POLICY "Acesso vinculado à análise principal - improvements"
ON public.essay_analysis_improvements FOR ALL
USING (
EXISTS (
SELECT 1
FROM public.essay_analyses_new a
JOIN public.student_essays e ON e.id = a.essay_id
WHERE a.id = analysis_id
AND e.student_id = auth.uid()
)
);
CREATE POLICY "Acesso vinculado à análise principal - scores"
ON public.essay_analysis_scores FOR ALL
USING (
EXISTS (
SELECT 1
FROM public.essay_analyses_new a
JOIN public.student_essays e ON e.id = a.essay_id
WHERE a.id = analysis_id
AND e.student_id = auth.uid()
)
);
-- Dropar a tabela antiga
DROP TABLE public.essay_analyses;
-- Renomear a nova tabela
ALTER TABLE public.essay_analyses_new RENAME TO essay_analyses;

View File

@ -0,0 +1,89 @@
-- Recriar a tabela original com os campos JSONB e arrays
CREATE TABLE public.essay_analyses_old (
id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
essay_id UUID NOT NULL REFERENCES public.student_essays(id),
overall_score INTEGER NOT NULL CHECK (overall_score >= 0 AND overall_score <= 100),
feedback JSONB NOT NULL DEFAULT '{}',
strengths TEXT[] DEFAULT ARRAY[]::TEXT[],
improvements TEXT[] DEFAULT ARRAY[]::TEXT[],
suggestions TEXT,
criteria_scores JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone('utc'::text, now()) NOT NULL
);
-- Migrar dados das tabelas normalizadas para a tabela original
INSERT INTO public.essay_analyses_old (
id,
essay_id,
overall_score,
feedback,
strengths,
improvements,
suggestions,
criteria_scores,
created_at
)
SELECT
a.id,
a.essay_id,
a.overall_score,
jsonb_build_object(
'structure', f.structure_feedback,
'content', f.content_feedback,
'language', f.language_feedback
) as feedback,
array_agg(DISTINCT s.strength) as strengths,
array_agg(DISTINCT i.improvement) as improvements,
a.suggestions,
jsonb_build_object(
'adequacy', sc.adequacy,
'coherence', sc.coherence,
'cohesion', sc.cohesion,
'vocabulary', sc.vocabulary,
'grammar', sc.grammar
) as criteria_scores,
a.created_at
FROM public.essay_analyses a
LEFT JOIN public.essay_analysis_feedback f ON f.analysis_id = a.id
LEFT JOIN public.essay_analysis_strengths s ON s.analysis_id = a.id
LEFT JOIN public.essay_analysis_improvements i ON i.analysis_id = a.id
LEFT JOIN public.essay_analysis_scores sc ON sc.analysis_id = a.id
GROUP BY a.id, a.essay_id, a.overall_score, a.suggestions, a.created_at,
f.structure_feedback, f.content_feedback, f.language_feedback,
sc.adequacy, sc.coherence, sc.cohesion, sc.vocabulary, sc.grammar;
-- Dropar as tabelas normalizadas
DROP TABLE IF EXISTS public.essay_analysis_scores;
DROP TABLE IF EXISTS public.essay_analysis_improvements;
DROP TABLE IF EXISTS public.essay_analysis_strengths;
DROP TABLE IF EXISTS public.essay_analysis_feedback;
DROP TABLE IF EXISTS public.essay_analyses;
-- Renomear a tabela antiga
ALTER TABLE public.essay_analyses_old RENAME TO essay_analyses;
-- Recriar as políticas originais
ALTER TABLE public.essay_analyses ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Edge Function pode inserir análises"
ON public.essay_analyses FOR INSERT
WITH CHECK (
(auth.jwt() ->> 'role' = 'service_role') OR
EXISTS (
SELECT 1
FROM public.student_essays
WHERE id = essay_id
AND student_id = auth.uid()
)
);
CREATE POLICY "Alunos podem ver análises de suas próprias redações"
ON public.essay_analyses FOR SELECT
USING (
EXISTS (
SELECT 1
FROM public.student_essays
WHERE id = essay_id
AND student_id = auth.uid()
)
);

View File

@ -1,12 +1,55 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
extend: {
fontFamily: {
sans: ['Inter', 'sans-serif'],
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
purple: {
50: '#f5f3ff',
100: '#ede9fe',
@ -32,56 +75,243 @@ export default {
900: '#1e3a8a',
},
},
animation: {
'in': 'in 200ms ease-in',
'out': 'out 200ms ease-out',
'slide-in-from-top': 'slide-in-from-top 200ms ease-out',
'slide-in-from-bottom': 'slide-in-from-bottom 200ms ease-out',
'slide-out-to-right': 'slide-out-to-right 200ms ease-out',
'fade-in': 'fade-in 200ms ease-in',
'fade-out': 'fade-out 200ms ease-out',
'scale-in': 'scale-in 200ms ease-out',
'scale-out': 'scale-out 200ms ease-in',
borderRadius: {
none: '0',
sm: '0.125rem',
DEFAULT: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem',
'2xl': '1rem',
'3xl': '1.5rem',
full: '9999px',
},
keyframes: {
in: {
'0%': { transform: 'translateX(100%)' },
'100%': { transform: 'translateX(0)' },
"fade-in": {
"0%": { opacity: 0 },
"100%": { opacity: 1 }
},
out: {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(100%)' },
},
'slide-in-from-top': {
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(0)' },
},
'slide-in-from-bottom': {
'0%': { transform: 'translateY(100%)' },
'100%': { transform: 'translateY(0)' },
},
'slide-out-to-right': {
'0%': { transform: 'translateX(0)' },
'100%': { transform: 'translateX(100%)' },
},
'fade-in': {
'0%': { opacity: 0 },
'100%': { opacity: 1 },
},
'fade-out': {
'0%': { opacity: 1 },
'100%': { opacity: 0 },
},
'scale-in': {
'0%': { transform: 'scale(0.95)', opacity: 0 },
'100%': { transform: 'scale(1)', opacity: 1 },
},
'scale-out': {
'0%': { transform: 'scale(1)', opacity: 1 },
'100%': { transform: 'scale(0.95)', opacity: 0 },
"fade-out": {
"0%": { opacity: 1 },
"100%": { opacity: 0 }
}
},
animation: {
"fade-in": "fade-in 200ms ease-in",
"fade-out": "fade-out 200ms ease-out"
},
typography: {
DEFAULT: {
css: {
maxWidth: '100%',
color: 'var(--tw-prose-body)',
'[class~="lead"]': {
color: 'var(--tw-prose-lead)',
},
a: {
color: 'var(--tw-prose-links)',
textDecoration: 'underline',
fontWeight: '500',
},
strong: {
color: 'var(--tw-prose-bold)',
fontWeight: '600',
},
'ol[type="A"]': {
'--list-counter-style': 'upper-alpha',
},
'ol[type="a"]': {
'--list-counter-style': 'lower-alpha',
},
'ol[type="A" s]': {
'--list-counter-style': 'upper-alpha',
},
'ol[type="a" s]': {
'--list-counter-style': 'lower-alpha',
},
'ol[type="I"]': {
'--list-counter-style': 'upper-roman',
},
'ol[type="i"]': {
'--list-counter-style': 'lower-roman',
},
'ol[type="I" s]': {
'--list-counter-style': 'upper-roman',
},
'ol[type="i" s]': {
'--list-counter-style': 'lower-roman',
},
'ol[type="1"]': {
'--list-counter-style': 'decimal',
},
'ol > li': {
position: 'relative',
},
'ol > li::before': {
content: 'counter(list-item, var(--list-counter-style, decimal)) "."',
position: 'absolute',
fontWeight: '400',
color: 'var(--tw-prose-counters)',
},
'ul > li': {
position: 'relative',
},
'ul > li::before': {
content: '""',
position: 'absolute',
backgroundColor: 'var(--tw-prose-bullets)',
borderRadius: '50%',
},
hr: {
borderColor: 'var(--tw-prose-hr)',
borderTopWidth: 1,
},
blockquote: {
fontWeight: '500',
fontStyle: 'italic',
color: 'var(--tw-prose-quotes)',
borderLeftWidth: '0.25rem',
borderLeftColor: 'var(--tw-prose-quote-borders)',
quotes: '"\\201C""\\201D""\\2018""\\2019"',
},
'blockquote p:first-of-type::before': {
content: 'open-quote',
},
'blockquote p:last-of-type::after': {
content: 'close-quote',
},
h1: {
color: 'var(--tw-prose-headings)',
fontWeight: '800',
},
'h1 strong': {
fontWeight: '900',
color: 'inherit',
},
h2: {
color: 'var(--tw-prose-headings)',
fontWeight: '700',
},
'h2 strong': {
fontWeight: '800',
color: 'inherit',
},
h3: {
color: 'var(--tw-prose-headings)',
fontWeight: '600',
},
'h3 strong': {
fontWeight: '700',
color: 'inherit',
},
h4: {
color: 'var(--tw-prose-headings)',
fontWeight: '600',
},
'h4 strong': {
fontWeight: '700',
color: 'inherit',
},
img: {
marginTop: '2em',
marginBottom: '2em',
},
'figure > *': {
marginTop: '0',
marginBottom: '0',
},
figcaption: {
color: 'var(--tw-prose-captions)',
fontSize: '0.875em',
lineHeight: '1.4285714',
marginTop: '0.8571429em',
},
code: {
color: 'var(--tw-prose-code)',
fontWeight: '600',
},
'code::before': {
content: '"`"',
},
'code::after': {
content: '"`"',
},
'a code': {
color: 'inherit',
},
'h1 code': {
color: 'inherit',
},
'h2 code': {
color: 'inherit',
},
'h3 code': {
color: 'inherit',
},
'h4 code': {
color: 'inherit',
},
'blockquote code': {
color: 'inherit',
},
'thead th code': {
color: 'inherit',
},
pre: {
color: 'var(--tw-prose-pre-code)',
backgroundColor: 'var(--tw-prose-pre-bg)',
overflowX: 'auto',
fontWeight: '400',
},
'pre code': {
backgroundColor: 'transparent',
borderWidth: '0',
borderRadius: '0',
padding: '0',
fontWeight: 'inherit',
color: 'inherit',
fontSize: 'inherit',
fontFamily: 'inherit',
lineHeight: 'inherit',
},
'pre code::before': {
content: 'none',
},
'pre code::after': {
content: 'none',
},
table: {
width: '100%',
tableLayout: 'auto',
textAlign: 'left',
marginTop: '2em',
marginBottom: '2em',
},
thead: {
borderBottomWidth: '1px',
borderBottomColor: 'var(--tw-prose-th-borders)',
},
'thead th': {
color: 'var(--tw-prose-headings)',
fontWeight: '600',
verticalAlign: 'bottom',
},
'tbody tr': {
borderBottomWidth: '1px',
borderBottomColor: 'var(--tw-prose-td-borders)',
},
'tbody tr:last-child': {
borderBottomWidth: '0',
},
'tbody td': {
verticalAlign: 'baseline',
},
},
},
},
},
},
plugins: [],
};
plugins: [
require('@tailwindcss/typography'),
],
}