mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-18 22:37:51 +00:00
Compare commits
No commits in common. "0c2a63dcd323226d8efeb2b30f35fe6d53ea9442" and "f37f8f2f6df4401c794d6db06137bc5e2e72bc67" have entirely different histories.
0c2a63dcd3
...
f37f8f2f6d
@ -48,6 +48,11 @@ 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)
|
||||||
|
|||||||
@ -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: https://*.supabase.co;
|
img-src 'self' data: https: blob:;
|
||||||
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:;
|
||||||
|
|||||||
@ -45,6 +45,7 @@ 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"
|
||||||
|
|||||||
@ -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, PhonicsProgress } from "@/types/phonics";
|
import type { PhonicsExercise, StudentPhonicsProgress } from "@/types/phonics";
|
||||||
|
|
||||||
interface ExerciseCardProps {
|
interface ExerciseCardProps {
|
||||||
exercise: PhonicsExercise;
|
exercise: PhonicsExercise;
|
||||||
progress?: PhonicsProgress;
|
progress?: StudentPhonicsProgress;
|
||||||
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.bestScore * 100) : 0;
|
const progressValue = progress ? (progress.best_score * 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.estimatedTimeSeconds / 60)} min</span>
|
<span>{Math.ceil((exercise.estimated_time_seconds ?? 0) / 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,6 +57,7 @@ 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"}
|
||||||
|
|||||||
@ -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.exerciseId === exercise.id)}
|
progress={progress?.find((p) => p.exercise_id === exercise.id)}
|
||||||
onStart={onSelectExercise}
|
onStart={onSelectExercise}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -2,15 +2,17 @@ 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 } from "@/hooks/phonics/useExerciseAttempt";
|
import { useExerciseWords, useExerciseAttempt } 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 } from "@/types/phonics";
|
import type { PhonicsExercise, PhonicsWord, PhonicsExerciseType } 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;
|
||||||
@ -32,6 +34,7 @@ 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(() => {
|
||||||
@ -65,17 +68,21 @@ 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({
|
||||||
studentId,
|
student_id: studentId,
|
||||||
exerciseId: exercise.id,
|
exercise_id: exercise.id,
|
||||||
attempts: 1,
|
score: finalScore,
|
||||||
bestScore: finalScore,
|
time_spent_seconds: timeSpent
|
||||||
lastScore: finalScore,
|
|
||||||
completed: finalScore >= exercise.requiredScore,
|
|
||||||
stars,
|
|
||||||
xpEarned: Math.round(finalScore * exercise.points)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onComplete();
|
onComplete();
|
||||||
@ -94,7 +101,8 @@ export function ExercisePlayer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const progress = ((currentStep + 1) / exerciseWords.length) * 100;
|
const progress = ((currentStep + 1) / exerciseWords.length) * 100;
|
||||||
const currentWord = exerciseWords[currentStep].word as unknown as PhonicsWord;
|
const currentWord = exerciseWords[currentStep].word;
|
||||||
|
const options = exerciseWords[currentStep].options;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={cn(
|
<Card className={cn(
|
||||||
@ -110,7 +118,12 @@ 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 variant="outline" size="sm" onClick={onExit}>
|
<Button
|
||||||
|
trackingId="exit-exercise"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={onExit}
|
||||||
|
>
|
||||||
Sair
|
Sair
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -125,9 +138,9 @@ export function ExercisePlayer({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ExerciseFactory
|
<ExerciseFactory
|
||||||
type={exercise.exerciseType}
|
type={exercise.type}
|
||||||
currentWord={currentWord}
|
currentWord={currentWord}
|
||||||
options={exerciseWords[currentStep].options}
|
options={options}
|
||||||
onAnswer={handleAnswer}
|
onAnswer={handleAnswer}
|
||||||
disabled={showFeedback}
|
disabled={showFeedback}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -27,6 +27,7 @@ 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}
|
||||||
|
|||||||
@ -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) {
|
switch (type.name) {
|
||||||
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}
|
Tipo de exercício não implementado: {type.name}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,7 @@ 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}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ 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}
|
||||||
|
|||||||
@ -44,6 +44,7 @@ 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}
|
||||||
@ -62,6 +63,7 @@ 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"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,27 +1,17 @@
|
|||||||
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 { PhonicsAttempt } from '@/types/phonics';
|
import type { StudentPhonicsAttempt, PhonicsWord } from '@/types/phonics';
|
||||||
|
|
||||||
export function useExerciseAttempt() {
|
interface AttemptParams {
|
||||||
const queryClient = useQueryClient();
|
student_id: string;
|
||||||
|
exercise_id: string;
|
||||||
|
score: number;
|
||||||
|
time_spent_seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
return useMutation({
|
interface ExerciseWord {
|
||||||
mutationFn: async (attempt: Omit<PhonicsAttempt, 'id'>) => {
|
word: PhonicsWord;
|
||||||
const { data, error } = await supabase
|
options: PhonicsWord[];
|
||||||
.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) {
|
||||||
@ -31,14 +21,54 @@ 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 (
|
||||||
word:phonics_words(*)
|
id,
|
||||||
|
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] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -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, PhonicsExerciseCategory } from '@/types/phonics';
|
import type { PhonicsExercise, PhonicsExerciseType, PhonicsExerciseCategory } from '@/types/phonics';
|
||||||
|
|
||||||
export function usePhonicsExercises(categoryId?: string) {
|
export function usePhonicsExercises(categoryId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@ -10,23 +10,20 @@ export function usePhonicsExercises(categoryId?: string) {
|
|||||||
.from('phonics_exercises')
|
.from('phonics_exercises')
|
||||||
.select(`
|
.select(`
|
||||||
*,
|
*,
|
||||||
category:phonics_exercise_categories(name),
|
type:phonics_exercise_types (*),
|
||||||
type:phonics_exercise_types(name),
|
category:phonics_exercise_categories (*)
|
||||||
words:phonics_exercise_words(
|
|
||||||
word:phonics_words(*)
|
|
||||||
)
|
|
||||||
`)
|
`)
|
||||||
.eq('is_active', true)
|
.eq('is_active', true)
|
||||||
.order('difficulty_level', { ascending: true });
|
.order('order_index', { 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[];
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,13 @@ 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],
|
||||||
@ -22,44 +29,19 @@ export function useUpdatePhonicsProgress() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async (params: UpdateProgressParams) => {
|
||||||
studentId,
|
const { data, error } = await supabase
|
||||||
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: studentId,
|
student_id: params.student_id,
|
||||||
exercise_id: exerciseId,
|
exercise_id: params.exercise_id,
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
best_score: score,
|
best_score: params.score,
|
||||||
last_score: score,
|
last_score: params.score,
|
||||||
completed: score >= 0.7,
|
completed: params.score >= 0.7,
|
||||||
completed_at: score >= 0.7 ? new Date().toISOString() : null,
|
completed_at: params.score >= 0.7 ? new Date().toISOString() : null,
|
||||||
stars: Math.ceil(score * 3),
|
stars: Math.ceil(params.score * 3),
|
||||||
xp_earned: Math.ceil(score * 100)
|
xp_earned: Math.ceil(params.score * 100)
|
||||||
}, {
|
}, {
|
||||||
onConflict: 'student_id,exercise_id',
|
onConflict: 'student_id,exercise_id',
|
||||||
ignoreDuplicates: false
|
ignoreDuplicates: false
|
||||||
@ -67,12 +49,11 @@ export function useUpdatePhonicsProgress() {
|
|||||||
.select()
|
.select()
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (progressError) throw progressError;
|
if (error) throw error;
|
||||||
|
return data;
|
||||||
return progressData;
|
|
||||||
},
|
},
|
||||||
onSuccess: (_, { studentId }) => {
|
onSuccess: (_, { student_id }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['phonics-progress', studentId] });
|
queryClient.invalidateQueries({ queryKey: ['phonics-progress', student_id] });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
35
src/lib/imageUtils.ts
Normal file
35
src/lib/imageUtils.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@ -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.xpEarned, 0);
|
const totalXP = progress.reduce((acc, p) => acc + p.xp_earned, 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.exerciseId === exercise.id);
|
const exerciseProgress = progress.find(p => p.exercise_id === exercise.id);
|
||||||
const progressValue = exerciseProgress ? exerciseProgress.bestScore * 100 : 0;
|
const progressValue = exerciseProgress ? exerciseProgress.best_score * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={exercise.id} className="flex items-center gap-4">
|
<div key={exercise.id} className="flex items-center gap-4">
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { ArrowLeft, ArrowRight, Volume2, Share2, ChevronDown, ChevronUp, Loader2, Pause, Play, Download, RefreshCw, Trash2 } from 'lucide-react';
|
import { ArrowLeft, ArrowRight, Mic, Volume2, Share2, Save, 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';
|
||||||
@ -383,6 +385,8 @@ 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);
|
||||||
|
|
||||||
@ -500,12 +504,15 @@ export function StoryPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Atualizar o useEffect para pré-carregar imagens
|
// Pré-carregar próxima imagem
|
||||||
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 = nextImageUrl;
|
nextImage.src = getOptimizedImageUrl(nextImageUrl, {
|
||||||
|
width: 1200,
|
||||||
|
quality: 85
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [currentPage, story]);
|
}, [currentPage, story]);
|
||||||
|
|
||||||
@ -628,7 +635,10 @@ 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={story.content.pages[currentPage].image}
|
src={getOptimizedImageUrl(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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -3,6 +3,7 @@ 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;
|
||||||
@ -252,10 +253,10 @@ export function StudentDashboardPage() {
|
|||||||
{story.cover && (
|
{story.cover && (
|
||||||
<div className="relative aspect-video">
|
<div className="relative aspect-video">
|
||||||
<img
|
<img
|
||||||
src={supabase.storage
|
src={getOptimizedImageUrl(story.cover.image_url, {
|
||||||
.from('story-images')
|
width: 400,
|
||||||
.getPublicUrl(story.cover.image_url).data.publicUrl +
|
height: 300
|
||||||
`?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"
|
||||||
|
|||||||
@ -3,6 +3,7 @@ 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';
|
||||||
@ -200,10 +201,11 @@ export function StudentStoriesPage() {
|
|||||||
{story.cover && (
|
{story.cover && (
|
||||||
<div className="relative aspect-video">
|
<div className="relative aspect-video">
|
||||||
<img
|
<img
|
||||||
src={supabase.storage
|
src={getOptimizedImageUrl(story.cover.image_url, {
|
||||||
.from('story-images')
|
width: 400,
|
||||||
.getPublicUrl(story.cover.image_url).data.publicUrl +
|
height: 300,
|
||||||
`?width=400&height=300&quality=80&format=webp`}
|
quality: 80
|
||||||
|
})}
|
||||||
alt={story.title}
|
alt={story.title}
|
||||||
className="w-full h-48 object-cover"
|
className="w-full h-48 object-cover"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
|
|||||||
@ -1,15 +1,35 @@
|
|||||||
project_id = "bsjlbnyslxzsdwxvkaap"
|
[project]
|
||||||
|
id = "bsjlbnyslxzsdwxvkaap"
|
||||||
|
name = "Leiturama"
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
enabled = true
|
enabled = true
|
||||||
site_url = "https://leiturama.ai"
|
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
|
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
|
||||||
@ -17,15 +37,86 @@ 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"
|
||||||
|
|
||||||
[api]
|
|
||||||
enabled = true
|
|
||||||
port = 54321
|
|
||||||
schemas = ["public", "storage", "auth"]
|
|
||||||
|
|
||||||
[storage]
|
[storage]
|
||||||
enabled = true
|
enabled = true
|
||||||
file_size_limit = "50MB"
|
file_size_limit = "50MB"
|
||||||
|
|
||||||
[functions]
|
[storage.cors]
|
||||||
[functions.generate-story]
|
allowed_origins = [
|
||||||
verify_jwt = true
|
"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]
|
||||||
|
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
|
||||||
|
|
||||||
|
[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
|
||||||
|
|||||||
@ -21,6 +21,15 @@ 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: {
|
||||||
@ -46,7 +55,6 @@ 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',
|
||||||
@ -228,7 +236,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 sem transformações
|
// Gerar URL público da imagem
|
||||||
const { data: publicUrl } = supabase
|
const { data: publicUrl } = supabase
|
||||||
.storage
|
.storage
|
||||||
.from('story-images')
|
.from('story-images')
|
||||||
@ -236,9 +244,12 @@ 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: publicUrl.publicUrl, // Salvar apenas o caminho do arquivo
|
image: optimizedUrl,
|
||||||
image_path: fileName
|
image_path: fileName
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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) => {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ 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 {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user