From 8475babb2c0dfae521b3f830f59185d7e1d47545 Mon Sep 17 00:00:00 2001 From: Lucas Santana Date: Mon, 23 Dec 2024 15:30:19 -0300 Subject: [PATCH] =?UTF-8?q?refactor:=20otimiza=20carregamento=20e=20visual?= =?UTF-8?q?iza=C3=A7=C3=A3o=20de=20imagens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implementa lazy loading e placeholders para imagens - Adiciona pré-carregamento da próxima imagem - Otimiza URLs de imagem com parâmetros de transformação - Padroniza visualização de cards de histórias - Ajusta estilos para consistência entre páginas - Implementa cache de imagens no frontend - Atualiza queries para usar story_pages como capa --- CHANGELOG.md | 35 ++- src/lib/imageCache.ts | 17 ++ src/pages/story/StoryPageDemo.tsx | 261 +++++++++++++++++- src/pages/student-dashboard/StoryPage.tsx | 57 +++- .../StudentDashboardPage.tsx | 41 ++- .../student-dashboard/StudentStoriesPage.tsx | 54 ++-- src/routes.tsx | 2 +- supabase/functions/generate-story/index.ts | 14 +- 8 files changed, 421 insertions(+), 60 deletions(-) create mode 100644 src/lib/imageCache.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc0b7d..09f26cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,16 +26,33 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/). - Relacionamentos explícitos entre histórias e páginas - Suporte a ordenação por número da página +- Otimização de carregamento de imagens + - Lazy loading com placeholders + - Pré-carregamento da próxima imagem + - Cache de imagens no frontend + - Transformações de imagem no Supabase Storage + - Múltiplas resoluções de imagem + +- Sistema de cache de imagens no frontend + - Implementação de imageCache.ts + - Prevenção de recarregamento desnecessário + - Melhor performance em navegação + ### Modificado +- Otimização de imagens de capa + - Uso da primeira página como capa + - Tamanho reduzido para thumbnails + - Carregamento lazy para melhor performance + +- Padronização da interface de histórias + - Consistência visual entre dashboard e lista + - Cards de história com mesmo estilo e comportamento + - Melhor experiência do usuário na navegação + - 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 @@ -45,6 +62,14 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/). - Feedback em tempo real do processo de geração - Queries otimizadas para nova estrutura de dados - Melhor tratamento de estados de loading e erro +- Implementação de componente ImageWithLoading +- Sistema de cache de imagens +- Otimização de URLs de imagem + +- Refatoração de componentes para melhor reuso + - Separação de lógica de carregamento de imagens + - Componentes mais modulares e reutilizáveis + - Melhor organização do código ### Segurança - Validação de dados de entrada na edge function diff --git a/src/lib/imageCache.ts b/src/lib/imageCache.ts new file mode 100644 index 0000000..9d58906 --- /dev/null +++ b/src/lib/imageCache.ts @@ -0,0 +1,17 @@ +const imageCache = new Map(); + +export function cacheImage(url: string): Promise { + if (imageCache.has(url)) { + return Promise.resolve(imageCache.get(url)!); + } + + return new Promise((resolve, reject) => { + const img = new Image(); + img.src = url; + img.onload = () => { + imageCache.set(url, url); + resolve(url); + }; + img.onerror = reject; + }); +} \ No newline at end of file diff --git a/src/pages/story/StoryPageDemo.tsx b/src/pages/story/StoryPageDemo.tsx index bfbf3d0..b296dc2 100644 --- a/src/pages/story/StoryPageDemo.tsx +++ b/src/pages/story/StoryPageDemo.tsx @@ -1,3 +1,260 @@ -export function StoryPageDemo({ demo = false }: StoryPageProps): JSX.Element { - // ... resto do código permanece igual ... +import React from 'react'; +import { ArrowLeft, ArrowRight, Mic, Volume2, Share2 } from 'lucide-react'; +import { AudioRecorder } from '../../components/story/AudioRecorder'; +import { StoryMetrics } from '../../components/story/StoryMetrics'; +import type { StoryRecording } from '../../types/database'; + +const demoRecording: StoryRecording = { + id: 'demo-recording', + fluency_score: 85, + pronunciation_score: 90, + accuracy_score: 88, + comprehension_score: 92, + words_per_minute: 120, + pause_count: 3, + error_count: 2, + self_corrections: 1, + strengths: [ + 'Ótima pronúncia das palavras', + 'Boa velocidade de leitura', + 'Excelente compreensão do texto' + ], + improvements: [ + 'Reduzir pequenas pausas entre frases', + 'Praticar palavras mais complexas' + ], + suggestions: 'Continue praticando a leitura em voz alta regularmente', + created_at: new Date().toISOString(), + processed_at: new Date().toISOString() +}; + +const demoStory = { + id: 'demo', + student_id: 'demo', + title: 'Uma Aventura Educacional', + content: { + pages: [ + { + text: 'Bem-vindo à demonstração do Histórias Mágicas! Aqui você pode ver como funciona nossa plataforma de leitura interativa...', + 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. Cada história é uma nova aventura!', + image: 'https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&q=80&w=800&h=600', + } + ] + }, + status: 'published', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() +}; + +function RecordingHistoryCard({ recording }: { recording: StoryRecording }) { + const [isExpanded, setIsExpanded] = React.useState(false); + + return ( +
+
+
+ + {new Date(recording.created_at).toLocaleDateString()} + +
+ +
+ + {isExpanded && ( +
+
+
+ Palavras por minuto: + {recording.words_per_minute} +
+
+ Pausas: + {recording.pause_count} +
+
+ Erros: + {recording.error_count} +
+
+ Autocorreções: + {recording.self_corrections} +
+
+ +
+
+
Pontos Fortes
+
    + {recording.strengths.map((strength, i) => ( +
  • {strength}
  • + ))} +
+
+ +
+
Pontos para Melhorar
+
    + {recording.improvements.map((improvement, i) => ( +
  • {improvement}
  • + ))} +
+
+ +
+
Sugestões
+

{recording.suggestions}

+
+
+
+ )} +
+ ); +} + +export function StoryPageDemo(): JSX.Element { + const [currentPage, setCurrentPage] = React.useState(0); + const [isPlaying, setIsPlaying] = React.useState(false); + const [recordings] = React.useState([demoRecording]); + + const metricsData = { + metrics: { + fluency: demoRecording.fluency_score, + pronunciation: demoRecording.pronunciation_score, + accuracy: demoRecording.accuracy_score, + comprehension: demoRecording.comprehension_score + }, + feedback: { + strengths: demoRecording.strengths, + improvements: demoRecording.improvements, + suggestions: demoRecording.suggestions + }, + details: { + wordsPerMinute: demoRecording.words_per_minute, + pauseCount: demoRecording.pause_count, + errorCount: demoRecording.error_count, + selfCorrections: demoRecording.self_corrections + } + }; + + const handleShare = () => { + alert('Funcionalidade de compartilhamento disponível apenas na versão completa!'); + }; + + return ( +
+
+
+ + +
+ + +
+
+ + {/* Dashboard de métricas */} + {recordings.length > 0 && ( + + )} + + {/* Histórico de gravações */} + {recordings.length > 0 && ( +
+

Histórico de Gravações

+
+ {recordings.map((recording) => ( + + ))} +
+
+ )} + +
+ {/* Imagem da página atual */} +
+ {`Página +
+ +
+

{demoStory.title}

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

+ {demoStory.content.pages[currentPage].text} +

+ + {/* Gravador de áudio */} + { + alert('Gravação disponível apenas na versão completa!'); + }} + /> + + {/* Navegação entre páginas */} +
+ + + + Página {currentPage + 1} de {demoStory.content.pages.length} + + + +
+
+
+
+
+ ); } \ No newline at end of file diff --git a/src/pages/student-dashboard/StoryPage.tsx b/src/pages/student-dashboard/StoryPage.tsx index c244dcd..a6e86ab 100644 --- a/src/pages/student-dashboard/StoryPage.tsx +++ b/src/pages/student-dashboard/StoryPage.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp } from 'lucide-react'; +import React, { useState, useEffect } from 'react'; +import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'; import { useParams, useNavigate } from 'react-router-dom'; import { supabase } from '../../lib/supabase'; import { AudioRecorder } from '../../components/story/AudioRecorder'; @@ -125,6 +125,39 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) { ); } +function ImageWithLoading({ src, alt, className }: { src: string; alt: string; className?: string }) { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + + return ( +
+ {isLoading && ( +
+ +
+ )} + {alt} setIsLoading(false)} + onError={() => { + setError(true); + setIsLoading(false); + }} + /> + {error && ( +
+

Erro ao carregar imagem

+
+ )} +
+ ); +} + export function StoryPage() { const navigate = useNavigate(); const { id } = useParams(); @@ -266,6 +299,14 @@ export function StoryPage() { } }); + // Pré-carregar próxima imagem + useEffect(() => { + if (story?.content?.pages?.[currentPage + 1]?.image) { + const nextImage = new Image(); + nextImage.src = story.content.pages[currentPage + 1].image; + } + }, [currentPage, story]); + if (loading) { return (
@@ -355,13 +396,11 @@ export function StoryPage() {
{/* Imagem da página atual */} {story?.content?.pages?.[currentPage]?.image && ( -
- {`Página -
+ )}
diff --git a/src/pages/student-dashboard/StudentDashboardPage.tsx b/src/pages/student-dashboard/StudentDashboardPage.tsx index 4a6972a..105b6f2 100644 --- a/src/pages/student-dashboard/StudentDashboardPage.tsx +++ b/src/pages/student-dashboard/StudentDashboardPage.tsx @@ -23,6 +23,7 @@ export function StudentDashboardPage() { }); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); + const [recentStories, setRecentStories] = React.useState([]); React.useEffect(() => { const fetchDashboardData = async () => { @@ -64,6 +65,23 @@ export function StudentDashboardPage() { currentLevel: 3 // Exemplo }); + // Buscar histórias recentes + const { data, error } = await supabase + .from('stories') + .select(` + *, + cover:story_pages!inner( + image_url + ) + `) + .eq('student_id', session.user.id) + .eq('story_pages.page_number', 1) + .order('created_at', { ascending: false }) + .limit(3); + + if (error) throw error; + setRecentStories(data || []); + } catch (err) { console.error('Erro ao carregar dashboard:', err); setError('Não foi possível carregar seus dados'); @@ -197,16 +215,16 @@ export function StudentDashboardPage() { {/* Histórias Recentes */}
-

Histórias Recentes

+

Histórias Recentes

- {stories.length === 0 ? ( + {recentStories.length === 0 ? (

@@ -225,18 +243,21 @@ export function StudentDashboardPage() {

) : (
- {stories.map((story) => ( + {recentStories.map((story) => (
navigate(`/aluno/historias/${story.id}`)} > - {story.content?.pages?.[0]?.image && ( - {story.title} + {story.cover && ( +
+ {story.title} +
)}

{story.title}

diff --git a/src/pages/student-dashboard/StudentStoriesPage.tsx b/src/pages/student-dashboard/StudentStoriesPage.tsx index 897262c..3974066 100644 --- a/src/pages/student-dashboard/StudentStoriesPage.tsx +++ b/src/pages/student-dashboard/StudentStoriesPage.tsx @@ -23,35 +23,22 @@ export function StudentStoriesPage() { const { data: { session } } = await supabase.auth.getSession(); if (!session?.user?.id) return; - const query = supabase + const { data, error } = await supabase .from('stories') - .select('*') - .eq('student_id', session.user.id); + .select(` + *, + cover:story_pages!inner( + image_url + ) + `) + .eq('student_id', session.user.id) + .eq('story_pages.page_number', 1) + .order('created_at', { ascending: false }); - if (statusFilter !== 'all') { - query.eq('status', statusFilter); - } - - let { data, error } = await query; if (error) throw error; - - // 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); - case 'performance': - return (b.performance_score || 0) - (a.performance_score || 0); - default: // recent - return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); - } - }); - - setStories(sortedData); + setStories(data || []); } catch (err) { - console.error('Erro ao buscar histórias:', err); + console.error('Erro ao carregar histórias:', err); setError('Não foi possível carregar suas histórias'); } finally { setLoading(false); @@ -59,7 +46,7 @@ export function StudentStoriesPage() { }; fetchStories(); - }, [statusFilter, sortBy]); + }, []); const filteredStories = stories.filter(story => story.title.toLowerCase().includes(searchTerm.toLowerCase()) @@ -197,12 +184,15 @@ export function StudentStoriesPage() { className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden cursor-pointer hover:shadow-md transition" onClick={() => navigate(`/aluno/historias/${story.id}`)} > - {story.content?.pages?.[0]?.image && ( - {story.title} + {story.cover && ( +
+ {story.title} +
)}

{story.title}

diff --git a/src/routes.tsx b/src/routes.tsx index f2ee96e..6ce7a66 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -125,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 34d7f19..b1a7528 100644 --- a/supabase/functions/generate-story/index.ts +++ b/supabase/functions/generate-story/index.ts @@ -20,6 +20,15 @@ const ALLOWED_ORIGINS = [ 'https://historiasmagicas.netlify.app' // Produção ]; +// Função para otimizar URL da imagem +function getOptimizedImageUrl(originalUrl: string, width = 800): string { + // Se já for uma URL do Supabase Storage, adicionar transformações + if (originalUrl.includes('storage.googleapis.com')) { + return `${originalUrl}?width=${width}&quality=80&format=webp`; + } + return originalUrl; +} + serve(async (req) => { const origin = req.headers.get('origin') || ''; const corsHeaders = { @@ -179,9 +188,12 @@ serve(async (req) => { console.log(`[Storage] Imagem ${index + 1} salva com sucesso`) + // Otimizar URL antes de salvar + const optimizedUrl = getOptimizedImageUrl(imageResponse.data[0].url); + return { text: page.text, - image: publicUrl.publicUrl, + image: optimizedUrl, image_path: fileName } })