refactor: atualiza estrutura de dados das histórias

- Migra dados das páginas para tabela story_pages
- Atualiza queries para usar nova estrutura
- Separa componente de demo em StoryPageDemo
- Mantém compatibilidade com interface existente
- Melhora tipagem e tratamento de erros
This commit is contained in:
Lucas Santana 2024-12-23 14:45:16 -03:00
parent 8af9950ed7
commit 961fce03f6
7 changed files with 146 additions and 241 deletions

View File

@ -21,11 +21,21 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
- Navegação automática entre etapas
- Tratamento de erros com feedback visual
- Nova estrutura de dados para páginas de histórias
- Tabela `story_pages` para melhor organização
- Relacionamentos explícitos entre histórias e páginas
- Suporte a ordenação por número da página
### Modificado
- Atualização do schema do banco para suportar novas categorias
- Adição de tabelas para temas, disciplinas, personagens e cenários
- Relacionamentos entre histórias e categorias
- Índices para otimização de consultas
- Renomeado componente StoryPage para StoryPageDemo para melhor organização
- Separado visualização de histórias demo da visualização principal
- Migração de dados das páginas para nova estrutura
- Mantida compatibilidade com interface existente
- Melhor organização e tipagem dos dados
### Técnico
- Implementação de logs estruturados com prefixos por contexto
@ -33,6 +43,8 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
- Tratamento de respostas da IA com fallbacks
- Otimização de queries no banco de dados
- Feedback em tempo real do processo de geração
- Queries otimizadas para nova estrutura de dados
- Melhor tratamento de estados de loading e erro
### Segurança
- Validação de dados de entrada na edge function

View File

@ -27,6 +27,12 @@ Histórias Mágicas é uma aplicação web desenvolvida em React que permite que
- Tailwind CSS
- Lucide React (ícones)
- Vite
- Supabase
- Supabase Functions
- OpenAI
- DALL-E
## 🚀 Como Executar

View File

@ -1,189 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { Story, StoryRecording } from '../../types/database';
import { AudioRecorder } from '../../components/story/AudioRecorder';
import { Loader2 } from 'lucide-react';
import type { MetricsData } from '../../components/story/StoryMetrics';
interface StoryPageProps {
demo?: boolean;
}
const demoRecording: StoryRecording = {
id: 'demo-recording',
fluency_score: 85,
pronunciation_score: 92,
accuracy_score: 88,
comprehension_score: 90,
words_per_minute: 120,
pause_count: 3,
error_count: 2,
self_corrections: 1,
strengths: [
'Leitura fluente e natural',
'Boa pronúncia das palavras',
'Respeito à pontuação'
],
improvements: [
'Pode melhorar o ritmo em algumas partes',
'Atenção a algumas palavras específicas'
],
suggestions: 'Tente manter um ritmo mais constante durante toda a leitura',
created_at: new Date().toISOString(),
processed_at: new Date().toISOString()
};
export function StoryPage({ demo = false }: StoryPageProps): JSX.Element {
const { storyId } = useParams();
const [story, setStory] = useState<Story | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [currentPage, setCurrentPage] = useState(0);
useEffect(() => {
const fetchStory = async () => {
try {
if (!storyId && !demo) {
throw new Error('ID da história não fornecido');
}
if (demo) {
setStory({
id: 'demo',
student_id: 'demo',
class_id: 'demo',
school_id: 'demo',
title: 'Uma Aventura Educacional',
theme: 'Demo',
content: {
pages: [
{
text: 'Bem-vindo à demonstração do Histórias Mágicas! Aqui você pode ver como funciona nossa plataforma...',
image: 'https://images.unsplash.com/photo-1472162072942-cd5147eb3902?auto=format&fit=crop&q=80&w=800&h=600',
},
{
text: 'Com histórias interativas e educativas, seus alunos aprenderão de forma divertida e envolvente...',
image: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&q=80&w=800&h=600',
}
],
currentPage: 0
},
status: 'published',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
setLoading(false);
return;
}
const { data, error } = await supabase
.from('stories')
.select('*')
.eq('id', storyId)
.single();
if (error) throw error;
setStory(data);
} catch (err) {
console.error('Erro ao buscar história:', err);
setError(err instanceof Error ? err.message : 'Erro ao carregar história');
} finally {
setLoading(false);
}
};
fetchStory();
}, [storyId, demo]);
const handleAudioUploaded = async (audioUrl: string) => {
try {
if (demo) return; // Não salva gravações no modo demo
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) throw new Error('Usuário não autenticado');
const { error } = await supabase
.from('story_recordings')
.insert({
story_id: storyId,
student_id: session.user.id,
audio_url: audioUrl,
status: 'pending_analysis'
});
if (error) throw error;
} catch (err) {
console.error('Erro ao salvar gravação:', err);
setError('Erro ao salvar gravação de áudio');
}
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="w-8 h-8 text-purple-600 animate-spin" />
</div>
);
}
if (error || !story) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ops!</h1>
<p className="text-gray-600">{error || 'História não encontrada'}</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-b from-purple-50 to-white py-12">
<div className="max-w-4xl mx-auto px-4">
<h1 className="text-3xl font-bold text-gray-900 mb-8 text-center">
{story.title}
</h1>
<div className="bg-white rounded-2xl shadow-xl overflow-hidden mb-8">
{story.content.pages[currentPage].image && (
<img
src={story.content.pages[currentPage].image}
alt={`Ilustração da página ${currentPage + 1}`}
className="w-full h-64 object-cover"
/>
)}
<div className="p-8">
<p className="text-lg text-gray-700 mb-8">
{story.content.pages[currentPage].text}
</p>
<AudioRecorder
storyId={story.id}
studentId={demo ? 'demo' : story.student_id}
onAudioUploaded={handleAudioUploaded}
/>
<div className="flex justify-between mt-8">
<button
onClick={() => setCurrentPage(prev => Math.max(0, prev - 1))}
disabled={currentPage === 0}
className="px-4 py-2 bg-purple-100 text-purple-700 rounded-lg disabled:opacity-50"
>
Anterior
</button>
<button
onClick={() => setCurrentPage(prev => Math.min(story.content.pages.length - 1, prev + 1))}
disabled={currentPage === story.content.pages.length - 1}
className="px-4 py-2 bg-purple-600 text-white rounded-lg disabled:opacity-50"
>
Próxima
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export function StoryPageDemo({ demo = false }: StoryPageProps): JSX.Element {
// ... resto do código permanece igual ...
}

View File

@ -126,32 +126,52 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
}
export function StoryPage() {
const { id } = useParams();
const navigate = useNavigate();
const { id } = useParams();
const [story, setStory] = React.useState<Story | null>(null);
const [currentPage, setCurrentPage] = React.useState(0);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const [isPlaying, setIsPlaying] = React.useState(false);
const [metrics, setMetrics] = React.useState<MetricsData | undefined>();
const [loadingMetrics, setLoadingMetrics] = React.useState(true);
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
const [metrics, setMetrics] = React.useState<MetricsData | null>(null);
const [loadingMetrics, setLoadingMetrics] = React.useState(true);
React.useEffect(() => {
const fetchStory = async () => {
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user?.id) return;
if (!id) throw new Error('ID da história não fornecido');
const { data, error } = await supabase
// Buscar história e suas páginas
const { data: storyData, error: storyError } = await supabase
.from('stories')
.select('*')
.select(`
*,
story_pages (
id,
page_number,
text,
image_url
)
`)
.eq('id', id)
.single();
if (error) throw error;
setStory(data);
if (storyError) throw storyError;
// Ordenar páginas por número
const orderedPages = storyData.story_pages.sort((a, b) => a.page_number - b.page_number);
setStory({
...storyData,
content: {
pages: orderedPages.map(page => ({
text: page.text,
image: page.image_url
}))
}
});
} catch (err) {
console.error('Erro ao carregar história:', err);
setError('Não foi possível carregar a história');
@ -334,7 +354,7 @@ export function StoryPage() {
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{/* Imagem da página atual */}
{story.content?.pages?.[currentPage]?.image && (
{story?.content?.pages?.[currentPage]?.image && (
<div className="relative aspect-video">
<img
src={story.content.pages[currentPage].image}
@ -345,11 +365,11 @@ export function StoryPage() {
)}
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-4">{story.title}</h1>
<h1 className="text-2xl font-bold text-gray-900 mb-4">{story?.title}</h1>
{/* Texto da página atual */}
<p className="text-lg text-gray-700 mb-8">
{story.content?.pages?.[currentPage]?.text || 'Carregando...'}
{story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
</p>
{/* Gravador de áudio */}

View File

@ -19,6 +19,7 @@ import { StudentDashboardLayout } from './pages/student-dashboard/StudentDashboa
import { StudentStoriesPage } from './pages/student-dashboard/StudentStoriesPage';
import { StudentSettingsPage } from './pages/student-dashboard/StudentSettingsPage';
import { CreateStoryPage } from './pages/student-dashboard/CreateStoryPage';
import { StoryPageDemo } from './pages/story/StoryPageDemo';
import { StoryPage } from './pages/student-dashboard/StoryPage';
import { ProtectedRoute } from './components/auth/ProtectedRoute';
import { UserManagementPage } from './pages/admin/UserManagementPage';
@ -124,7 +125,7 @@ export const router = createBrowserRouter([
},
{
path: '/demo',
element: <StoryViewer demo={true} />,
element: <StoryPageDemo />,
},
{
path: '/auth/callback',

View File

@ -40,7 +40,7 @@ serve(async (req) => {
try {
const supabase = createClient(
Deno.env.get('SUPABASE_URL') ?? '',
Deno.env.get('SUPABASE_ANON_KEY') ?? ''
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
)
console.log('[Supabase] Cliente inicializado')
@ -139,70 +139,122 @@ serve(async (req) => {
const pages = await Promise.all(
storyContent.pages.map(async (page: any, index: number) => {
console.log(`[DALL-E] Gerando imagem ${index + 1}/${storyContent.pages.length}...`)
// Gerar imagem com DALL-E
const imageResponse = await openai.images.generate({
prompt: `${page.image_prompt}. Style: children's book illustration, colorful, educational, safe for kids`,
n: 1,
size: "1024x1024"
})
console.log(`[DALL-E] Imagem ${index + 1} gerada com sucesso`)
// Baixar a imagem do URL do DALL-E
console.log(`[Storage] Baixando imagem ${index + 1}...`)
const imageUrl = imageResponse.data[0].url
const imageRes = await fetch(imageUrl)
const imageBuffer = await imageRes.arrayBuffer()
// Gerar nome único para o arquivo
const fileName = `${record.id}/page-${index + 1}-${Date.now()}.png`
// Salvar no Storage do Supabase
console.log(`[Storage] Salvando imagem ${index + 1} no bucket...`)
const { data: storageData, error: storageError } = await supabase
.storage
.from('story-images')
.upload(fileName, imageBuffer, {
contentType: 'image/png',
cacheControl: '3600',
upsert: false
})
if (storageError) {
throw new Error(`Erro ao salvar imagem ${index + 1} no storage: ${storageError.message}`)
}
// Gerar URL público da imagem
const { data: publicUrl } = supabase
.storage
.from('story-images')
.getPublicUrl(fileName)
console.log(`[Storage] Imagem ${index + 1} salva com sucesso`)
return {
text: page.text,
image: imageResponse.data[0].url
image: publicUrl.publicUrl,
image_path: fileName
}
})
)
console.log('[DALL-E] Todas as imagens geradas com sucesso')
// Preparar dados para salvar
const storyData = {
// Preparar e salvar os dados
const { data: story, error: storyError } = await supabase
.from('stories')
.update({
title: storyContent.title,
content: {
title: storyContent.title,
pages: pages,
theme: theme.title,
subject: subject.title,
character: character.title,
setting: setting.title,
context: record.context,
original_prompt: prompt,
ai_response: completion.choices[0].message.content
},
status: 'published',
theme_id: theme.id,
subject_id: subject.id,
character_id: character.id,
setting_id: setting.id,
context: record.context,
updated_at: new Date().toISOString()
}
console.log('[DB] Dados para salvar:', storyData)
// Atualizar história no Supabase
console.log('[DB] Salvando história...')
const { data: savedStory, error: updateError } = await supabase
.from('stories')
.update(storyData)
})
.eq('id', record.id)
.select()
.single()
.single();
if (updateError) {
throw new Error(`Erro ao salvar história: ${updateError.message}`);
}
if (storyError) throw new Error(`Erro ao atualizar história: ${storyError.message}`);
console.log('[DB] História salva com sucesso:', savedStory)
// Salvar páginas
const { error: pagesError } = await supabase
.from('story_pages')
.insert(
pages.map((page, index) => ({
story_id: record.id,
page_number: index + 1,
text: page.text,
image_url: page.image,
image_path: page.image_path
}))
);
if (pagesError) throw new Error(`Erro ao salvar páginas: ${pagesError.message}`);
// Salvar metadados da geração
const { error: genError } = await supabase
.from('story_generations')
.insert({
story_id: record.id,
original_prompt: prompt,
ai_response: completion.choices[0].message.content,
model_used: 'gpt-4-turbo'
});
if (genError) throw new Error(`Erro ao salvar metadados: ${genError.message}`);
// Buscar história completa com relacionamentos
const { data: fullStory, error: fetchError } = await supabase
.from('stories')
.select(`
*,
pages:story_pages(*)
`)
.eq('id', record.id)
.single();
if (fetchError) throw new Error(`Erro ao buscar história completa: ${fetchError.message}`);
return new Response(
JSON.stringify({
success: true,
message: 'História gerada e salva com sucesso',
storyId: record.id,
story: savedStory
story: fullStory
}),
{ headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
)
);
} catch (error) {
console.error('[Error] Erro ao gerar história:', error)