fix: corrigindo image_url na functions generate-story

This commit is contained in:
Lucas Santana 2025-01-18 12:15:46 -03:00
parent f37f8f2f6d
commit 5d4c9b6d49
20 changed files with 119 additions and 303 deletions

View File

@ -48,11 +48,6 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
- Criação de índices para otimização de consultas
- Implementação de políticas de segurança RLS
- Estrutura de dados normalizada com relacionamentos apropriados
- Corrigido tipo de retorno em `useExerciseWords` para garantir formato correto de palavra e opções
- Ajustado `usePhonicsExercises` para aceitar filtro por categoria
- Atualizada query de palavras do exercício para usar inner join e ordenação
- Adicionadas interfaces `AttemptParams` e `ExerciseWord` para melhor tipagem
- Corrigidos nomes de propriedades para seguir convenção snake_case em todos os hooks
### Modificado
- N/A (primeira versão)

View File

@ -45,7 +45,6 @@ export function AudioPlayer({ word, disabled }: AudioPlayerProps) {
return (
<div>
<Button
trackingId="play-word-audio"
variant="ghost"
size="lg"
className="gap-2"

View File

@ -3,18 +3,18 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Clock, Star } from "lucide-react";
import type { PhonicsExercise, StudentPhonicsProgress } from "@/types/phonics";
import type { PhonicsExercise, PhonicsProgress } from "@/types/phonics";
interface ExerciseCardProps {
exercise: PhonicsExercise;
progress?: StudentPhonicsProgress;
progress?: PhonicsProgress;
onStart: (exerciseId: string) => void;
}
export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps) {
const isCompleted = progress?.completed;
const stars = progress?.stars || 0;
const progressValue = progress ? (progress.best_score * 100) : 0;
const progressValue = progress ? (progress.bestScore * 100) : 0;
return (
<Card className="w-full hover:shadow-lg transition-shadow">
@ -34,7 +34,7 @@ export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps)
<div className="flex items-center justify-between text-sm text-muted-foreground">
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>{Math.ceil((exercise.estimated_time_seconds ?? 0) / 60)} min</span>
<span>{Math.ceil(exercise.estimatedTimeSeconds / 60)} min</span>
</div>
<div className="flex items-center gap-1">
{Array.from({ length: 3 }).map((_, i) => (
@ -57,7 +57,6 @@ export function ExerciseCard({ exercise, progress, onStart }: ExerciseCardProps)
)}
<Button
trackingId="start-exercise"
className="w-full"
onClick={() => onStart(exercise.id)}
variant={isCompleted ? "secondary" : "default"}

View File

@ -39,7 +39,7 @@ export function ExerciseGrid({ categoryId, studentId, onSelectExercise }: Exerci
<ExerciseCard
key={exercise.id}
exercise={exercise}
progress={progress?.find((p) => p.exercise_id === exercise.id)}
progress={progress?.find((p) => p.exerciseId === exercise.id)}
onStart={onSelectExercise}
/>
))}

View File

@ -2,17 +2,15 @@ import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { useExerciseWords, useExerciseAttempt } from "@/hooks/phonics/useExerciseAttempt";
import { useExerciseWords } from "@/hooks/phonics/useExerciseAttempt";
import { useUpdatePhonicsProgress } from "@/hooks/phonics/usePhonicsProgress";
import { ExerciseFactory } from "./exercises/ExerciseFactory";
import { Timer } from "lucide-react";
import type { PhonicsExercise, PhonicsWord, PhonicsExerciseType } from "@/types/phonics";
import type { PhonicsExercise, PhonicsWord } from "@/types/phonics";
import { cn } from "@/lib/utils";
interface ExercisePlayerProps {
exercise: PhonicsExercise & {
type: PhonicsExerciseType;
};
exercise: PhonicsExercise;
studentId: string;
onComplete: () => void;
onExit: () => void;
@ -34,7 +32,6 @@ export function ExercisePlayer({
const { data: exerciseWords, isLoading } = useExerciseWords(exercise.id);
const updateProgress = useUpdatePhonicsProgress();
const exerciseAttempt = useExerciseAttempt();
useEffect(() => {
const timer = setInterval(() => {
@ -68,21 +65,17 @@ export function ExercisePlayer({
const handleComplete = async () => {
const finalScore = score / (exerciseWords?.length || 1);
const stars = Math.ceil(finalScore * 3);
// Primeiro registra a tentativa
await exerciseAttempt.mutateAsync({
student_id: studentId,
exercise_id: exercise.id,
score: finalScore,
time_spent_seconds: timeSpent
});
// Depois atualiza o progresso
await updateProgress.mutateAsync({
student_id: studentId,
exercise_id: exercise.id,
score: finalScore,
time_spent_seconds: timeSpent
studentId,
exerciseId: exercise.id,
attempts: 1,
bestScore: finalScore,
lastScore: finalScore,
completed: finalScore >= exercise.requiredScore,
stars,
xpEarned: Math.round(finalScore * exercise.points)
});
onComplete();
@ -101,8 +94,7 @@ export function ExercisePlayer({
}
const progress = ((currentStep + 1) / exerciseWords.length) * 100;
const currentWord = exerciseWords[currentStep].word;
const options = exerciseWords[currentStep].options;
const currentWord = exerciseWords[currentStep].word as unknown as PhonicsWord;
return (
<Card className={cn(
@ -118,12 +110,7 @@ export function ExercisePlayer({
<Timer className="w-4 h-4" />
<span>{Math.floor(timeSpent / 60)}:{(timeSpent % 60).toString().padStart(2, '0')}</span>
</div>
<Button
trackingId="exit-exercise"
variant="outline"
size="sm"
onClick={onExit}
>
<Button variant="outline" size="sm" onClick={onExit}>
Sair
</Button>
</div>
@ -138,9 +125,9 @@ export function ExercisePlayer({
</div>
<ExerciseFactory
type={exercise.type}
type={exercise.exerciseType}
currentWord={currentWord}
options={options}
options={exerciseWords[currentStep].options}
onAnswer={handleAnswer}
disabled={showFeedback}
/>

View File

@ -27,7 +27,6 @@ export function AlliterationExercise({
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
trackingId={`alliteration-option-${option.word}`}
key={option.word}
onClick={() => onAnswer(option.word, option.hasSameInitialSound)}
disabled={disabled}

View File

@ -13,7 +13,7 @@ interface ExerciseFactoryProps {
}
export function ExerciseFactory({ type, currentWord, options, onAnswer, disabled }: ExerciseFactoryProps) {
switch (type.name) {
switch (type) {
case 'rhyme':
return (
<RhymeExercise
@ -70,7 +70,7 @@ export function ExerciseFactory({ type, currentWord, options, onAnswer, disabled
default:
return (
<div className="text-center text-red-500">
Tipo de exercício não implementado: {type.name}
Tipo de exercício não implementado: {type}
</div>
);
}

View File

@ -22,7 +22,6 @@ export function RhymeExercise({ currentWord, onAnswer, options, disabled }: Rhym
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
trackingId={`rhyme-option-${option.word}`}
key={option.word}
onClick={() => onAnswer(option.word, option.isRhyme)}
disabled={disabled}

View File

@ -33,7 +33,6 @@ export function SoundMatchExercise({
<div className="grid grid-cols-2 gap-4">
{options.map((option) => (
<Button
trackingId={`sound-match-option-${option.word}`}
key={option.word}
onClick={() => onAnswer(option.word, option.hasMatchingSound)}
disabled={disabled}

View File

@ -44,7 +44,6 @@ export function SyllablesExercise({
<div className="flex flex-wrap justify-center gap-2">
{syllables.map((syllable, index) => (
<Button
trackingId={`syllable-option-${index}`}
key={index}
onClick={() => handleSyllableClick(index)}
disabled={disabled}
@ -63,7 +62,6 @@ export function SyllablesExercise({
{selectedSyllables.length > 0 && !disabled && (
<div className="flex justify-center">
<Button
trackingId="check-syllables"
onClick={handleCheck}
className="mt-4"
>

View File

@ -1,17 +1,27 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase';
import type { StudentPhonicsAttempt, PhonicsWord } from '@/types/phonics';
import type { PhonicsAttempt } from '@/types/phonics';
interface AttemptParams {
student_id: string;
exercise_id: string;
score: number;
time_spent_seconds: number;
}
export function useExerciseAttempt() {
const queryClient = useQueryClient();
interface ExerciseWord {
word: PhonicsWord;
options: PhonicsWord[];
return useMutation({
mutationFn: async (attempt: Omit<PhonicsAttempt, 'id'>) => {
const { data, error } = await supabase
.from('phonics_student_attempts')
.insert(attempt)
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: (data, variables) => {
queryClient.invalidateQueries({
queryKey: ['phonics-progress', variables.studentId]
});
}
});
}
export function useExerciseWords(exerciseId: string) {
@ -21,54 +31,14 @@ export function useExerciseWords(exerciseId: string) {
const { data, error } = await supabase
.from('phonics_exercise_words')
.select(`
word:phonics_words!inner (
id,
word,
audio_url,
phonetic_transcription,
syllables_count,
created_at
),
options:phonics_words!inner (
id,
word,
audio_url,
phonetic_transcription,
syllables_count,
created_at
)
*,
word:phonics_words(*)
`)
.eq('exercise_id', exerciseId)
.order('order_index', { ascending: true });
if (error) throw error;
// Transformar os dados para o formato correto
return (data || []).map(item => ({
word: item.word[0],
options: item.options
})) as ExerciseWord[];
},
enabled: !!exerciseId
});
}
export function useExerciseAttempt() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: AttemptParams) => {
const { data, error } = await supabase
.from('student_phonics_attempts')
.insert(params)
.select()
.single();
if (error) throw error;
return data;
},
onSuccess: (_, { student_id }) => {
queryClient.invalidateQueries({ queryKey: ['phonics-progress', student_id] });
}
});
}

View File

@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase';
import type { PhonicsExercise, PhonicsExerciseType, PhonicsExerciseCategory } from '@/types/phonics';
import type { PhonicsExercise, PhonicsExerciseCategory } from '@/types/phonics';
export function usePhonicsExercises(categoryId?: string) {
return useQuery({
@ -10,20 +10,23 @@ export function usePhonicsExercises(categoryId?: string) {
.from('phonics_exercises')
.select(`
*,
type:phonics_exercise_types (*),
category:phonics_exercise_categories (*)
category:phonics_exercise_categories(name),
type:phonics_exercise_types(name),
words:phonics_exercise_words(
word:phonics_words(*)
)
`)
.eq('is_active', true)
.order('order_index', { ascending: true });
.order('difficulty_level', { ascending: true });
if (categoryId) {
query.eq('category_id', categoryId);
}
const { data, error } = await query;
if (error) throw error;
return data as (PhonicsExercise & { type: PhonicsExerciseType })[];
return data as PhonicsExercise[];
}
});
}

View File

@ -2,13 +2,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase';
import type { StudentPhonicsProgress } from '@/types/phonics';
interface UpdateProgressParams {
student_id: string;
exercise_id: string;
score: number;
time_spent_seconds: number;
}
export function usePhonicsProgress(studentId: string) {
return useQuery({
queryKey: ['phonics-progress', studentId],
@ -29,19 +22,44 @@ export function useUpdatePhonicsProgress() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (params: UpdateProgressParams) => {
const { data, error } = await supabase
mutationFn: async ({
studentId,
exerciseId,
score,
timeSpent
}: {
studentId: string;
exerciseId: string;
score: number;
timeSpent: number;
}) => {
// Primeiro, registra a tentativa
const { data: attemptData, error: attemptError } = await supabase
.from('student_phonics_attempts')
.insert({
student_id: studentId,
exercise_id: exerciseId,
score,
time_spent_seconds: timeSpent
})
.select()
.single();
if (attemptError) throw attemptError;
// Depois, atualiza ou cria o progresso
const { data: progressData, error: progressError } = await supabase
.from('student_phonics_progress')
.upsert({
student_id: params.student_id,
exercise_id: params.exercise_id,
student_id: studentId,
exercise_id: exerciseId,
attempts: 1,
best_score: params.score,
last_score: params.score,
completed: params.score >= 0.7,
completed_at: params.score >= 0.7 ? new Date().toISOString() : null,
stars: Math.ceil(params.score * 3),
xp_earned: Math.ceil(params.score * 100)
best_score: score,
last_score: score,
completed: score >= 0.7,
completed_at: score >= 0.7 ? new Date().toISOString() : null,
stars: Math.ceil(score * 3),
xp_earned: Math.ceil(score * 100)
}, {
onConflict: 'student_id,exercise_id',
ignoreDuplicates: false
@ -49,11 +67,12 @@ export function useUpdatePhonicsProgress() {
.select()
.single();
if (error) throw error;
return data;
if (progressError) throw progressError;
return progressData;
},
onSuccess: (_, { student_id }) => {
queryClient.invalidateQueries({ queryKey: ['phonics-progress', student_id] });
onSuccess: (_, { studentId }) => {
queryClient.invalidateQueries({ queryKey: ['phonics-progress', studentId] });
}
});
}

View File

@ -1,35 +0,0 @@
interface ImageOptions {
width?: number;
height?: number;
quality?: number;
}
export function getOptimizedImageUrl(url: string | undefined, options: ImageOptions = {}): string {
// Retorna uma imagem padrão ou vazia se a URL for undefined
if (!url) {
return '/placeholder-image.jpg'; // ou retorne uma imagem padrão apropriada
}
const {
width = 800,
height = undefined,
quality = 80
} = options;
// Se for URL do Supabase Storage
if (url.includes('storage.googleapis.com')) {
const params = new URLSearchParams({
width: width.toString(),
quality: quality.toString(),
format: 'webp'
});
if (height) {
params.append('height', height.toString());
}
return `${url}?${params.toString()}`;
}
return url;
}

View File

@ -15,7 +15,7 @@ export function PhonicsProgressPage() {
const totalExercises = exercises.length;
const completedExercises = progress.filter(p => p.completed).length;
const totalStars = progress.reduce((acc, p) => acc + p.stars, 0);
const totalXP = progress.reduce((acc, p) => acc + p.xp_earned, 0);
const totalXP = progress.reduce((acc, p) => acc + p.xpEarned, 0);
const completionRate = (completedExercises / totalExercises) * 100;
return (
@ -74,8 +74,8 @@ export function PhonicsProgressPage() {
<CardContent>
<div className="space-y-4">
{exercises.map((exercise) => {
const exerciseProgress = progress.find(p => p.exercise_id === exercise.id);
const progressValue = exerciseProgress ? exerciseProgress.best_score * 100 : 0;
const exerciseProgress = progress.find(p => p.exerciseId === exercise.id);
const progressValue = exerciseProgress ? exerciseProgress.bestScore * 100 : 0;
return (
<div key={exercise.id} className="flex items-center gap-4">

View File

@ -1,12 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp, Loader2, Pause, Play, Download, RefreshCw, Trash2 } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { ArrowLeft, ArrowRight, Volume2, Share2, ChevronDown, ChevronUp, Loader2, Pause, Play, Download, RefreshCw, Trash2 } from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { AudioRecorder } from '../../components/story/AudioRecorder';
import type { Story } from '../../types/database';
import { StoryMetrics } from '../../components/story/StoryMetrics';
import type { MetricsData } from '../../components/story/StoryMetrics';
import { getOptimizedImageUrl } from '../../lib/imageUtils';
import { convertWebmToMp3 } from '../../utils/audioConverter';
import * as Dialog from '@radix-ui/react-dialog';
import { ExerciseSuggestions } from '../../components/learning/ExerciseSuggestions';
@ -385,8 +383,6 @@ export function StoryPage() {
const [isPlaying, setIsPlaying] = React.useState(false);
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);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@ -504,15 +500,12 @@ export function StoryPage() {
}
});
// Pré-carregar próxima imagem
// Atualizar o useEffect para pré-carregar imagens
useEffect(() => {
const nextImageUrl = story?.content?.pages?.[currentPage + 1]?.image;
if (nextImageUrl) {
const nextImage = new Image();
nextImage.src = getOptimizedImageUrl(nextImageUrl, {
width: 1200,
quality: 85
});
nextImage.src = nextImageUrl;
}
}, [currentPage, story]);
@ -635,10 +628,7 @@ export function StoryPage() {
{/* Imagem da página atual */}
{story?.content?.pages?.[currentPage]?.image && (
<ImageWithLoading
src={getOptimizedImageUrl(story.content.pages[currentPage].image, {
width: 1200,
quality: 85
})}
src={story.content.pages[currentPage].image}
alt={`Página ${currentPage + 1}`}
className="w-full h-full object-cover"
/>

View File

@ -3,7 +3,6 @@ import { Plus, BookOpen, Clock, TrendingUp, Award } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import type { Story, Student } from '../../types/database';
import { getOptimizedImageUrl } from '../../lib/imageUtils';
interface DashboardMetrics {
totalStories: number;
@ -253,10 +252,10 @@ export function StudentDashboardPage() {
{story.cover && (
<div className="relative aspect-video">
<img
src={getOptimizedImageUrl(story.cover.image_url, {
width: 400,
height: 300
})}
src={supabase.storage
.from('story-images')
.getPublicUrl(story.cover.image_url).data.publicUrl +
`?width=400&height=300&quality=80&format=webp`}
alt={story.title}
className="w-full h-48 object-cover"
loading="lazy"

View File

@ -3,7 +3,6 @@ import { Plus, Search, Filter, BookOpen, ArrowUpDown } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import type { Story } from '../../types/database';
import { getOptimizedImageUrl } from '../../lib/imageUtils';
type StoryStatus = 'all' | 'draft' | 'published';
type SortOption = 'recent' | 'oldest' | 'title' | 'performance';
@ -201,11 +200,10 @@ export function StudentStoriesPage() {
{story.cover && (
<div className="relative aspect-video">
<img
src={getOptimizedImageUrl(story.cover.image_url, {
width: 400,
height: 300,
quality: 80
})}
src={supabase.storage
.from('story-images')
.getPublicUrl(story.cover.image_url).data.publicUrl +
`?width=400&height=300&quality=80&format=webp`}
alt={story.title}
className="w-full h-48 object-cover"
loading="lazy"

View File

@ -1,35 +1,15 @@
[project]
id = "bsjlbnyslxzsdwxvkaap"
name = "Leiturama"
project_id = "bsjlbnyslxzsdwxvkaap"
[auth]
enabled = true
site_url = "https://leiturama.ai"
additional_redirect_urls = [
"https://leiturama.ai/*",
"http://localhost:5173/*",
"http://localhost:3000/*"
]
additional_redirect_urls = ["https://leiturama.ai/*", "http://localhost:5173/*", "http://localhost:3000/*"]
jwt_expiry = 3600
enable_refresh_token_rotation = true
refresh_token_reuse_interval = 10
[auth.mfa.totp]
enroll_enabled = true
verify_enabled = true
[auth.email]
enable_signup = true
double_confirm_changes = true
enable_confirmations = true
secure_password_change = true
max_frequency = "1m0s"
otp_length = 6
otp_expiry = 86400
[auth.external]
enabled = true
providers = ["google"]
[auth.external.google]
enabled = true
@ -37,86 +17,15 @@ client_id = "your-client-id"
secret = "your-client-secret"
redirect_uri = "https://leiturama.ai/auth/callback"
[storage]
enabled = true
file_size_limit = "50MB"
[storage.cors]
allowed_origins = [
"https://leiturama.ai",
"http://localhost:5173",
"http://localhost:3000"
]
allowed_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowed_headers = [
"Authorization",
"Content-Type",
"Accept",
"Origin",
"User-Agent",
"DNT",
"Cache-Control",
"X-Mx-ReqToken",
"Keep-Alive",
"X-Requested-With",
"If-Modified-Since"
]
exposed_headers = ["Content-Range", "Range"]
max_age = 3600
[api]
enabled = true
port = 54321
schemas = ["public", "storage", "auth"]
extra_search_path = ["public", "extensions"]
max_rows = 1000
[api.cors]
[storage]
enabled = true
allowed_origins = [
"https://leiturama.ai",
"http://localhost:5173",
"http://localhost:3000"
]
allowed_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowed_headers = [
"Authorization",
"Content-Type",
"Accept",
"Origin",
"User-Agent",
"DNT",
"Cache-Control",
"X-Mx-ReqToken",
"Keep-Alive",
"X-Requested-With",
"If-Modified-Since"
]
exposed_headers = ["Content-Range", "Range"]
max_age = 3600
file_size_limit = "50MB"
[db]
port = 54322
shadow_port = 54320
major_version = 15
[db.pooler]
enabled = false
port = 54329
pool_mode = "transaction"
default_pool_size = 15
max_client_conn = 100
[studio]
enabled = true
port = 54323
api_url = "https://leiturama.ai"
[inbucket]
enabled = true
port = 54324
smtp_port = 54325
pop3_port = 54326
[storage.backend]
enabled = true
[functions]
[functions.generate-story]
verify_jwt = true

View File

@ -21,15 +21,6 @@ const ALLOWED_ORIGINS = [
'https://leiturama.ai' // 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;
}
interface StoryResponse {
title: string;
content: {
@ -236,7 +227,7 @@ serve(async (req) => {
throw new Error(`Erro ao salvar imagem ${index + 1} no storage: ${storageError.message}`)
}
// Gerar URL público da imagem
// Gerar URL público da imagem sem transformações
const { data: publicUrl } = supabase
.storage
.from('story-images')
@ -244,12 +235,9 @@ 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: optimizedUrl,
image: publicUrl.publicUrl, // Salvar apenas o caminho do arquivo
image_path: fileName
}
})