diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e51750..8bc0b7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index e15dc9f..c1932ef 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pages/story/StoryPage.tsx b/src/pages/story/StoryPage.tsx deleted file mode 100644 index 048bcf4..0000000 --- a/src/pages/story/StoryPage.tsx +++ /dev/null @@ -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(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( -
- -
- ); - } - - if (error || !story) { - return ( -
-
-

Ops!

-

{error || 'História não encontrada'}

-
-
- ); - } - - return ( -
-
-

- {story.title} -

- -
- {story.content.pages[currentPage].image && ( - {`Ilustração - )} - -
-

- {story.content.pages[currentPage].text} -

- - - -
- - -
-
-
-
-
- ); -} \ No newline at end of file diff --git a/src/pages/story/StoryPageDemo.tsx b/src/pages/story/StoryPageDemo.tsx new file mode 100644 index 0000000..bfbf3d0 --- /dev/null +++ b/src/pages/story/StoryPageDemo.tsx @@ -0,0 +1,3 @@ +export function StoryPageDemo({ demo = false }: StoryPageProps): JSX.Element { + // ... resto do código permanece igual ... +} \ No newline at end of file diff --git a/src/pages/student-dashboard/StoryPage.tsx b/src/pages/student-dashboard/StoryPage.tsx index a92b71b..c244dcd 100644 --- a/src/pages/student-dashboard/StoryPage.tsx +++ b/src/pages/student-dashboard/StoryPage.tsx @@ -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(null); const [currentPage, setCurrentPage] = React.useState(0); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [isPlaying, setIsPlaying] = React.useState(false); - const [metrics, setMetrics] = React.useState(); - const [loadingMetrics, setLoadingMetrics] = React.useState(true); const [recordings, setRecordings] = React.useState([]); const [loadingRecordings, setLoadingRecordings] = React.useState(true); + const [metrics, setMetrics] = React.useState(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() {
{/* Imagem da página atual */} - {story.content?.pages?.[currentPage]?.image && ( + {story?.content?.pages?.[currentPage]?.image && (
-

{story.title}

+

{story?.title}

{/* Texto da página atual */}

- {story.content?.pages?.[currentPage]?.text || 'Carregando...'} + {story?.content?.pages?.[currentPage]?.text || 'Carregando...'}

{/* Gravador de áudio */} diff --git a/src/routes.tsx b/src/routes.tsx index 646b1b2..f2ee96e 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -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: , + element: , }, { path: '/auth/callback', diff --git a/supabase/functions/generate-story/index.ts b/supabase/functions/generate-story/index.ts index b4e1acf..34d7f19 100644 --- a/supabase/functions/generate-story/index.ts +++ b/supabase/functions/generate-story/index.ts @@ -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 = { - 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, - 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 + // Preparar e salvar os dados + const { data: story, error: storyError } = await supabase .from('stories') - .update(storyData) + .update({ + title: storyContent.title, + 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() + }) .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)