mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-17 05:47:52 +00:00
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:
parent
8af9950ed7
commit
961fce03f6
12
CHANGELOG.md
12
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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
3
src/pages/story/StoryPageDemo.tsx
Normal file
3
src/pages/story/StoryPageDemo.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function StoryPageDemo({ demo = false }: StoryPageProps): JSX.Element {
|
||||
// ... resto do código permanece igual ...
|
||||
}
|
||||
@ -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 */}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user