Compare commits

...

2 Commits

Author SHA1 Message Date
Lucas Santana
0c2a63dcd3 fix: corrigindo CORS
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-18 17:42:58 -03:00
Lucas Santana
5d4c9b6d49 fix: corrigindo image_url na functions generate-story 2025-01-18 12:15:46 -03:00
23 changed files with 123 additions and 304 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 - Criação de índices para otimização de consultas
- Implementação de políticas de segurança RLS - Implementação de políticas de segurança RLS
- Estrutura de dados normalizada com relacionamentos apropriados - 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 ### Modificado
- N/A (primeira versão) - N/A (primeira versão)

View File

@ -29,7 +29,7 @@
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.rudderlabs.com https://*.cloudfront.net https://www.googletagmanager.com https://*.sentry.io; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.rudderlabs.com https://*.cloudfront.net https://www.googletagmanager.com https://*.sentry.io;
connect-src 'self' https://*.rudderlabs.com https://*.ingest.sentry.io https://*.supabase.co https://www.google-analytics.com https://*.dataplane.rudderstack.com https://*.bugsnag.com/ https://*.ingest.us.sentry.io/ https://*.sentry.io/; connect-src 'self' https://*.rudderlabs.com https://*.ingest.sentry.io https://*.supabase.co https://www.google-analytics.com https://*.dataplane.rudderstack.com https://*.bugsnag.com/ https://*.ingest.us.sentry.io/ https://*.sentry.io/;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' data: https: blob:; img-src 'self' data: https: blob: https://*.supabase.co;
font-src 'self' data: https://fonts.gstatic.com; font-src 'self' data: https://fonts.gstatic.com;
frame-src 'self' https://www.googletagmanager.com; frame-src 'self' https://www.googletagmanager.com;
worker-src 'self' blob:; worker-src 'self' blob:;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ interface ExerciseFactoryProps {
} }
export function ExerciseFactory({ type, currentWord, options, onAnswer, disabled }: ExerciseFactoryProps) { export function ExerciseFactory({ type, currentWord, options, onAnswer, disabled }: ExerciseFactoryProps) {
switch (type.name) { switch (type) {
case 'rhyme': case 'rhyme':
return ( return (
<RhymeExercise <RhymeExercise
@ -70,7 +70,7 @@ export function ExerciseFactory({ type, currentWord, options, onAnswer, disabled
default: default:
return ( return (
<div className="text-center text-red-500"> <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> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@ -1,17 +1,27 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import type { StudentPhonicsAttempt, PhonicsWord } from '@/types/phonics'; import type { PhonicsAttempt } from '@/types/phonics';
interface AttemptParams { export function useExerciseAttempt() {
student_id: string; const queryClient = useQueryClient();
exercise_id: string;
score: number;
time_spent_seconds: number;
}
interface ExerciseWord { return useMutation({
word: PhonicsWord; mutationFn: async (attempt: Omit<PhonicsAttempt, 'id'>) => {
options: PhonicsWord[]; 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) { export function useExerciseWords(exerciseId: string) {
@ -21,54 +31,14 @@ export function useExerciseWords(exerciseId: string) {
const { data, error } = await supabase const { data, error } = await supabase
.from('phonics_exercise_words') .from('phonics_exercise_words')
.select(` .select(`
word:phonics_words!inner ( *,
id, word:phonics_words(*)
word,
audio_url,
phonetic_transcription,
syllables_count,
created_at
),
options:phonics_words!inner (
id,
word,
audio_url,
phonetic_transcription,
syllables_count,
created_at
)
`) `)
.eq('exercise_id', exerciseId) .eq('exercise_id', exerciseId)
.order('order_index', { ascending: true }); .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; if (error) throw error;
return data; 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 { useQuery } from '@tanstack/react-query';
import { supabase } from '@/lib/supabase'; 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) { export function usePhonicsExercises(categoryId?: string) {
return useQuery({ return useQuery({
@ -10,20 +10,23 @@ export function usePhonicsExercises(categoryId?: string) {
.from('phonics_exercises') .from('phonics_exercises')
.select(` .select(`
*, *,
type:phonics_exercise_types (*), category:phonics_exercise_categories(name),
category:phonics_exercise_categories (*) type:phonics_exercise_types(name),
words:phonics_exercise_words(
word:phonics_words(*)
)
`) `)
.eq('is_active', true) .eq('is_active', true)
.order('order_index', { ascending: true }); .order('difficulty_level', { ascending: true });
if (categoryId) { if (categoryId) {
query.eq('category_id', categoryId); query.eq('category_id', categoryId);
} }
const { data, error } = await query; const { data, error } = await query;
if (error) throw error; 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 { supabase } from '@/lib/supabase';
import type { StudentPhonicsProgress } from '@/types/phonics'; 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) { export function usePhonicsProgress(studentId: string) {
return useQuery({ return useQuery({
queryKey: ['phonics-progress', studentId], queryKey: ['phonics-progress', studentId],
@ -29,19 +22,44 @@ export function useUpdatePhonicsProgress() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (params: UpdateProgressParams) => { mutationFn: async ({
const { data, error } = await supabase 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') .from('student_phonics_progress')
.upsert({ .upsert({
student_id: params.student_id, student_id: studentId,
exercise_id: params.exercise_id, exercise_id: exerciseId,
attempts: 1, attempts: 1,
best_score: params.score, best_score: score,
last_score: params.score, last_score: score,
completed: params.score >= 0.7, completed: score >= 0.7,
completed_at: params.score >= 0.7 ? new Date().toISOString() : null, completed_at: score >= 0.7 ? new Date().toISOString() : null,
stars: Math.ceil(params.score * 3), stars: Math.ceil(score * 3),
xp_earned: Math.ceil(params.score * 100) xp_earned: Math.ceil(score * 100)
}, { }, {
onConflict: 'student_id,exercise_id', onConflict: 'student_id,exercise_id',
ignoreDuplicates: false ignoreDuplicates: false
@ -49,11 +67,12 @@ export function useUpdatePhonicsProgress() {
.select() .select()
.single(); .single();
if (error) throw error; if (progressError) throw progressError;
return data;
return progressData;
}, },
onSuccess: (_, { student_id }) => { onSuccess: (_, { studentId }) => {
queryClient.invalidateQueries({ queryKey: ['phonics-progress', student_id] }); 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 totalExercises = exercises.length;
const completedExercises = progress.filter(p => p.completed).length; const completedExercises = progress.filter(p => p.completed).length;
const totalStars = progress.reduce((acc, p) => acc + p.stars, 0); 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; const completionRate = (completedExercises / totalExercises) * 100;
return ( return (
@ -74,8 +74,8 @@ export function PhonicsProgressPage() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{exercises.map((exercise) => { {exercises.map((exercise) => {
const exerciseProgress = progress.find(p => p.exercise_id === exercise.id); const exerciseProgress = progress.find(p => p.exerciseId === exercise.id);
const progressValue = exerciseProgress ? exerciseProgress.best_score * 100 : 0; const progressValue = exerciseProgress ? exerciseProgress.bestScore * 100 : 0;
return ( return (
<div key={exercise.id} className="flex items-center gap-4"> <div key={exercise.id} className="flex items-center gap-4">

View File

@ -1,12 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect } from 'react';
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, ChevronDown, ChevronUp, Loader2, Pause, Play, Download, RefreshCw, Trash2 } from 'lucide-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 { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import { AudioRecorder } from '../../components/story/AudioRecorder'; import { AudioRecorder } from '../../components/story/AudioRecorder';
import type { Story } from '../../types/database'; import type { Story } from '../../types/database';
import { StoryMetrics } from '../../components/story/StoryMetrics'; import { StoryMetrics } from '../../components/story/StoryMetrics';
import type { MetricsData } from '../../components/story/StoryMetrics';
import { getOptimizedImageUrl } from '../../lib/imageUtils';
import { convertWebmToMp3 } from '../../utils/audioConverter'; import { convertWebmToMp3 } from '../../utils/audioConverter';
import * as Dialog from '@radix-ui/react-dialog'; import * as Dialog from '@radix-ui/react-dialog';
import { ExerciseSuggestions } from '../../components/learning/ExerciseSuggestions'; import { ExerciseSuggestions } from '../../components/learning/ExerciseSuggestions';
@ -385,8 +383,6 @@ export function StoryPage() {
const [isPlaying, setIsPlaying] = React.useState(false); const [isPlaying, setIsPlaying] = React.useState(false);
const [recordings, setRecordings] = React.useState<StoryRecording[]>([]); const [recordings, setRecordings] = React.useState<StoryRecording[]>([]);
const [loadingRecordings, setLoadingRecordings] = React.useState(true); 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 [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = 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(() => { useEffect(() => {
const nextImageUrl = story?.content?.pages?.[currentPage + 1]?.image; const nextImageUrl = story?.content?.pages?.[currentPage + 1]?.image;
if (nextImageUrl) { if (nextImageUrl) {
const nextImage = new Image(); const nextImage = new Image();
nextImage.src = getOptimizedImageUrl(nextImageUrl, { nextImage.src = nextImageUrl;
width: 1200,
quality: 85
});
} }
}, [currentPage, story]); }, [currentPage, story]);
@ -635,10 +628,7 @@ export function StoryPage() {
{/* Imagem da página atual */} {/* Imagem da página atual */}
{story?.content?.pages?.[currentPage]?.image && ( {story?.content?.pages?.[currentPage]?.image && (
<ImageWithLoading <ImageWithLoading
src={getOptimizedImageUrl(story.content.pages[currentPage].image, { src={story.content.pages[currentPage].image}
width: 1200,
quality: 85
})}
alt={`Página ${currentPage + 1}`} alt={`Página ${currentPage + 1}`}
className="w-full h-full object-cover" 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 { useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase'; import { supabase } from '../../lib/supabase';
import type { Story, Student } from '../../types/database'; import type { Story, Student } from '../../types/database';
import { getOptimizedImageUrl } from '../../lib/imageUtils';
interface DashboardMetrics { interface DashboardMetrics {
totalStories: number; totalStories: number;
@ -253,10 +252,10 @@ export function StudentDashboardPage() {
{story.cover && ( {story.cover && (
<div className="relative aspect-video"> <div className="relative aspect-video">
<img <img
src={getOptimizedImageUrl(story.cover.image_url, { src={supabase.storage
width: 400, .from('story-images')
height: 300 .getPublicUrl(story.cover.image_url).data.publicUrl +
})} `?width=400&height=300&quality=80&format=webp`}
alt={story.title} alt={story.title}
className="w-full h-48 object-cover" className="w-full h-48 object-cover"
loading="lazy" loading="lazy"

View File

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

View File

@ -1,35 +1,15 @@
[project] project_id = "bsjlbnyslxzsdwxvkaap"
id = "bsjlbnyslxzsdwxvkaap"
name = "Leiturama"
[auth] [auth]
enabled = true enabled = true
site_url = "https://leiturama.ai" site_url = "https://leiturama.ai"
additional_redirect_urls = [ additional_redirect_urls = ["https://leiturama.ai/*", "http://localhost:5173/*", "http://localhost:3000/*"]
"https://leiturama.ai/*",
"http://localhost:5173/*",
"http://localhost:3000/*"
]
jwt_expiry = 3600 jwt_expiry = 3600
enable_refresh_token_rotation = true
refresh_token_reuse_interval = 10
[auth.mfa.totp]
enroll_enabled = true
verify_enabled = true
[auth.email] [auth.email]
enable_signup = true enable_signup = true
double_confirm_changes = true double_confirm_changes = true
enable_confirmations = 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] [auth.external.google]
enabled = true enabled = true
@ -37,86 +17,15 @@ client_id = "your-client-id"
secret = "your-client-secret" secret = "your-client-secret"
redirect_uri = "https://leiturama.ai/auth/callback" 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] [api]
enabled = true enabled = true
port = 54321 port = 54321
schemas = ["public", "storage", "auth"] schemas = ["public", "storage", "auth"]
extra_search_path = ["public", "extensions"]
max_rows = 1000
[api.cors] [storage]
enabled = true enabled = true
allowed_origins = [ file_size_limit = "50MB"
"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
[db] [functions]
port = 54322 [functions.generate-story]
shadow_port = 54320 verify_jwt = true
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

View File

@ -21,15 +21,6 @@ const ALLOWED_ORIGINS = [
'https://leiturama.ai' // Produção '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 { interface StoryResponse {
title: string; title: string;
content: { content: {
@ -55,6 +46,7 @@ interface StoryResponse {
serve(async (req) => { serve(async (req) => {
const origin = req.headers.get('origin') || ''; const origin = req.headers.get('origin') || '';
const corsHeaders = { const corsHeaders = {
'Cross-Origin-Resource-Policy': 'cross-origin',
'Access-Control-Allow-Origin': ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0], 'Access-Control-Allow-Origin': ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0],
'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
@ -236,7 +228,7 @@ serve(async (req) => {
throw new Error(`Erro ao salvar imagem ${index + 1} no storage: ${storageError.message}`) 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 const { data: publicUrl } = supabase
.storage .storage
.from('story-images') .from('story-images')
@ -244,12 +236,9 @@ serve(async (req) => {
console.log(`[Storage] Imagem ${index + 1} salva com sucesso`) console.log(`[Storage] Imagem ${index + 1} salva com sucesso`)
// Otimizar URL antes de salvar
const optimizedUrl = getOptimizedImageUrl(imageResponse.data[0].url);
return { return {
text: page.text, text: page.text,
image: optimizedUrl, image: publicUrl.publicUrl, // Salvar apenas o caminho do arquivo
image_path: fileName image_path: fileName
} }
}) })

View File

@ -4,6 +4,7 @@ import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Cross-Origin-Resource-Policy': 'cross-origin'
}; };
serve(async (req) => { serve(async (req) => {

View File

@ -7,6 +7,7 @@ import { createLogger } from './logger.ts'
const corsHeaders = { const corsHeaders = {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
'Cross-Origin-Resource-Policy': 'cross-origin'
} }
interface AudioRecord { interface AudioRecord {