Compare commits

...

6 Commits

Author SHA1 Message Date
Lucas Santana
62594f5e62 fix: Capas
Some checks are pending
Docker Build and Push / build (push) Waiting to run
2025-01-23 19:59:01 -03:00
Lucas Santana
e154dd2372 fix: Corrigindo capas das histórias 2025-01-23 19:01:03 -03:00
Lucas Santana
ea5c5e87f1 feat: Adicionando separação de sílabas 2025-01-23 16:49:12 -03:00
Lucas Santana
229a1bffbb feat: Adiciona toggle de texto maiúsculo para apoio à alfabetização
- Implementa componente TextCaseToggle para alternância de caixa
- Cria sistema de texto adaptativo com componentes AdaptiveText
- Adiciona hook useUppercasePreference para gerenciar estado
- Integra funcionalidade em todas as páginas principais
- Persiste preferência do usuário no banco de dados
2025-01-23 15:30:35 -03:00
Lucas Santana
e4c225ebd7 feat: Adiciona toggle de texto maiúsculo para apoio à alfabetização
- Implementa componente TextCaseToggle para alternância de caixa
- Cria sistema de texto adaptativo com componentes AdaptiveText
- Adiciona hook useUppercasePreference para gerenciar estado
- Integra funcionalidade em todas as páginas principais
- Persiste preferência do usuário no banco de dados
2025-01-23 15:29:08 -03:00
Lucas Santana
7880ce8dda feat: Adicionando transformação de texto para maiúsculo 2025-01-23 13:28:13 -03:00
16 changed files with 465 additions and 37 deletions

View File

@ -51,6 +51,26 @@ e este projeto adere ao [Semantic Versioning](https://semver.org/lang/pt-BR/).
### Modificado
- N/A (primeira versão)
- Todas as páginas principais para usar texto adaptativo
- Componentes de exercícios para suportar transformação de texto
- Movido controle de sílabas para a página de histórias
### Removido
- N/A (primeira versão)
## [1.1.0] - 2024-05-20
### Adicionado
- Suporte a texto maiúsculo para alfabetização infantil
- Componente de alternância de caixa de texto
- Sistema de persistência de preferências
- Destaque silábico interativo para apoio à decodificação
### Modificado
- Todas as páginas principais para usar texto adaptativo
- Componentes de exercícios para suportar transformação de texto
### Técnico
- Nova coluna na tabela students
- Hook para gerenciamento de estado
- Otimizações de performance

View File

@ -0,0 +1,15 @@
## Destaque Silábico
**Objetivo:**
Facilitar a identificação das sílabas durante a leitura
**Como usar:**
1. Clique no botão "Sílabas" ao lado do título da história
2. Todas as palavras serão divididas em sílabas
3. Sílabas destacadas em fundo amarelo
4. Clique novamente para desativar
**Benefícios:**
- Auxilia na decodificação fonêmica
- Promove consciência silábica
- Facilita a leitura de palavras complexas

View File

@ -7,6 +7,8 @@ import { ExerciseFactory } from "./exercises/ExerciseFactory";
import { Timer } from "lucide-react";
import type { PhonicsExercise, UpdateProgressParams } from "@/types/phonics";
import { cn } from "@/lib/utils";
import { AdaptiveText } from '../ui/adaptive-text';
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
interface ExercisePlayerProps {
exercise: PhonicsExercise;
@ -33,6 +35,7 @@ export function ExercisePlayer({
const [lastAnswerCorrect, setLastAnswerCorrect] = useState<boolean | null>(null);
const updateProgress = useUpdatePhonicsProgress();
const { isUpperCase } = useUppercasePreference();
useEffect(() => {
const timer = setInterval(() => {
@ -150,6 +153,12 @@ export function ExercisePlayer({
Exercício {currentStep + 1} de {correctWords.length}
</div>
<AdaptiveText
text={exercise.instructions}
isUpperCase={isUpperCase}
className="text-lg"
/>
<ExerciseFactory
type_id={exercise.type_id}
currentWord={currentWord.word}

View File

@ -0,0 +1,94 @@
import React from 'react';
import { cn } from '../../lib/utils';
import { SyllableHighlighter } from '../../features/syllables/components/SyllableHighlighter';
interface AdaptiveTextProps extends React.HTMLAttributes<HTMLSpanElement> {
text: string;
isUpperCase: boolean;
as?: keyof JSX.IntrinsicElements;
preserveWhitespace?: boolean;
highlightSyllables?: boolean;
}
export const AdaptiveText = React.memo(({
text,
isUpperCase,
as: Component = 'span',
preserveWhitespace = false,
highlightSyllables = false,
className,
...props
}: AdaptiveTextProps) => {
// Transformar o texto mantendo espaços em branco se necessário
const transformedText = React.useMemo(() => {
const transformed = isUpperCase ? text.toUpperCase() : text;
return preserveWhitespace ? transformed : transformed.trim();
}, [text, isUpperCase, preserveWhitespace]);
return React.createElement(
Component,
{
className: cn(
'transition-colors duration-200',
className
),
...props
},
highlightSyllables ? (
<SyllableHighlighter text={transformedText} />
) : transformedText
);
});
AdaptiveText.displayName = 'AdaptiveText';
// Variantes específicas para diferentes contextos
export const AdaptiveTitle = ({
className,
...props
}: AdaptiveTextProps) => (
<AdaptiveText
as="h1"
className={cn(
'text-2xl font-bold text-gray-900',
className
)}
{...props}
/>
);
export const AdaptiveParagraph = ({
className,
...props
}: AdaptiveTextProps) => (
<AdaptiveText
as="p"
className={cn(
'text-base text-gray-700 leading-relaxed',
className
)}
{...props}
/>
);
export const AdaptiveLabel = ({
className,
...props
}: AdaptiveTextProps) => (
<AdaptiveText
as="span"
className={cn(
'text-sm font-medium text-gray-600',
className
)}
{...props}
/>
);
// Hook para memoização de textos longos
export function useAdaptiveText(text: string, isUpperCase: boolean) {
return React.useMemo(
() => isUpperCase ? text.toUpperCase() : text,
[text, isUpperCase]
);
}

View File

@ -0,0 +1,40 @@
import React from 'react';
import { Loader2, Type } from 'lucide-react';
import { cn } from '../../lib/utils';
interface TextCaseToggleProps {
isUpperCase: boolean;
onToggle: () => void;
isLoading?: boolean;
className?: string;
}
export function TextCaseToggle({
isUpperCase,
onToggle,
isLoading = false,
className
}: TextCaseToggleProps) {
return (
<button
onClick={onToggle}
disabled={isLoading}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors',
'text-gray-600 hover:text-gray-900 hover:bg-gray-100',
'border border-gray-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
className
)}
title={isUpperCase ? 'Mudar para minúsculas' : 'Mudar para maiúsculas'}
>
<Type className="h-4 w-4" />
<span className="text-sm font-medium select-none">
{isUpperCase ? 'Aa' : 'AA'}
</span>
{isLoading && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
</button>
);
}

View File

@ -0,0 +1,29 @@
import React from 'react';
import { splitIntoSyllables } from '../utils/syllableSplitter';
interface SyllableHighlighterProps {
text: string;
highlightColor?: string;
className?: string;
}
export function SyllableHighlighter({
text,
highlightColor = 'bg-yellow-100',
className
}: SyllableHighlighterProps) {
const syllables = splitIntoSyllables(text);
return (
<div className={className}>
{syllables.map((syllable, index) => (
<span
key={index}
className={`px-1 rounded ${highlightColor}`}
>
{syllable}
</span>
))}
</div>
);
}

View File

@ -0,0 +1,10 @@
import { useState } from 'react';
export function useSyllables() {
const [isHighlighted, setIsHighlighted] = useState(false);
return {
isHighlighted,
toggleHighlight: () => setIsHighlighted(!isHighlighted)
};
}

View File

@ -0,0 +1,8 @@
import { splitIntoSyllables } from './syllableSplitter';
import { test, expect } from 'vitest';
test('Separação silábica básica', () => {
expect(splitIntoSyllables('casa')).toEqual(['ca', 'sa']);
expect(splitIntoSyllables('banana')).toEqual(['ba', 'na', 'na']);
expect(splitIntoSyllables('computador')).toEqual(['com', 'pu', 'ta', 'dor']);
});

View File

@ -0,0 +1,22 @@
const VOWELS = new Set(['a', 'e', 'i', 'o', 'u', 'á', 'é', 'í', 'ó', 'ú', 'â', 'ê', 'ô', 'ã', 'õ']);
export function splitIntoSyllables(word: string): string[] {
const syllables: string[] = [];
let currentSyllable = '';
for (let i = 0; i < word.length; i++) {
const char = word[i].toLowerCase();
currentSyllable += word[i];
if (VOWELS.has(char)) {
if (i < word.length - 1 && !VOWELS.has(word[i+1].toLowerCase())) {
syllables.push(currentSyllable);
currentSyllable = '';
} else if (i === word.length - 1) {
syllables.push(currentSyllable);
}
}
}
return syllables.length > 0 ? syllables : [word];
}

View File

@ -0,0 +1,74 @@
import { useState, useEffect } from 'react';
import { supabase } from '../lib/supabase';
interface UseUppercasePreferenceReturn {
isUpperCase: boolean;
toggleUppercase: () => Promise<void>;
isLoading: boolean;
error: string | null;
}
export function useUppercasePreference(studentId?: string): UseUppercasePreferenceReturn {
const [isUpperCase, setIsUpperCase] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadPreference = async () => {
if (!studentId) {
setIsLoading(false);
return;
}
try {
const { data, error: fetchError } = await supabase
.from('students')
.select('uppercase_text_preferences')
.eq('id', studentId)
.single();
if (fetchError) throw fetchError;
setIsUpperCase(data?.uppercase_text_preferences ?? false);
setError(null);
} catch (err) {
console.error('Erro ao carregar preferência de texto:', err);
setError('Não foi possível carregar sua preferência de texto');
} finally {
setIsLoading(false);
}
};
loadPreference();
}, [studentId]);
const toggleUppercase = async () => {
if (!studentId || isLoading) return;
try {
setIsLoading(true);
const { error: updateError } = await supabase
.from('students')
.update({ uppercase_text_preferences: !isUpperCase })
.eq('id', studentId);
if (updateError) throw updateError;
setIsUpperCase(!isUpperCase);
setError(null);
} catch (err) {
console.error('Erro ao atualizar preferência de texto:', err);
setError('Não foi possível atualizar sua preferência de texto');
} finally {
setIsLoading(false);
}
};
return {
isUpperCase,
toggleUppercase,
isLoading,
error
};
}

View File

@ -3,12 +3,17 @@ import { ArrowLeft, Sparkles } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { StoryGenerator } from '../../components/story/StoryGenerator';
import { useSession } from '../../hooks/useSession';
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
import { AdaptiveTitle, AdaptiveParagraph, AdaptiveText } from '../../components/ui/adaptive-text';
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
export function CreateStoryPage() {
const navigate = useNavigate();
const { session } = useSession();
const [error, setError] = React.useState<string | null>(null);
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
if (!session) {
return (
<div className="text-center py-12">
@ -39,16 +44,31 @@ export function CreateStoryPage() {
<Sparkles className="h-6 w-6 text-purple-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Criar Nova História</h1>
<p className="text-gray-600">
Vamos criar uma história personalizada baseada nos seus interesses
</p>
<AdaptiveTitle
text="Criar Nova História"
isUpperCase={isUpperCase}
className="text-2xl font-bold text-gray-900"
/>
<AdaptiveParagraph
text="Vamos criar uma história personalizada baseada nos seus interesses"
isUpperCase={isUpperCase}
className="text-gray-600"
/>
</div>
<TextCaseToggle
isUpperCase={isUpperCase}
onToggle={toggleUppercase}
isLoading={isLoading}
className="ml-auto"
/>
</div>
{error && (
<div className="mb-6 p-4 bg-red-50 text-red-600 rounded-lg">
{error}
<AdaptiveText
text={error}
isUpperCase={isUpperCase}
/>
</div>
)}
@ -59,10 +79,19 @@ export function CreateStoryPage() {
Como funciona?
</h3>
<ol className="text-sm text-purple-700 space-y-2">
<li>1. Conte-nos sobre seus interesses e preferências</li>
<li>2. Escolha personagens e cenários para sua história</li>
<li>3. Nossa IA criará uma história única e personalizada</li>
<li>4. Você poderá ler e praticar com sua nova história</li>
{[
'Conte-nos sobre seus interesses e preferências',
'Escolha personagens e cenários para sua história',
'Nossa IA criará uma história única e personalizada',
'Você poderá ler e praticar com sua nova história'
].map((text, index) => (
<AdaptiveText
key={index}
text={`${index + 1}. ${text}`}
isUpperCase={isUpperCase}
as="li"
/>
))}
</ol>
</div>
</div>

View File

@ -5,6 +5,10 @@ import { WordFormation } from '../../components/exercises/WordFormation';
import { SentenceCompletion } from '../../components/exercises/SentenceCompletion';
import { PronunciationPractice } from '../../components/exercises/PronunciationPractice';
import { ArrowLeft } from 'lucide-react';
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
import { AdaptiveText, AdaptiveTitle, AdaptiveParagraph } from '../../components/ui/adaptive-text';
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
import { useSession } from '../../hooks/useSession';
interface ExerciseWord {
word: string;
@ -31,6 +35,8 @@ export function ExercisePage() {
const [exerciseData, setExerciseData] = React.useState<any>(null);
const [exerciseWords, setExerciseWords] = React.useState<ExerciseWord[]>([]);
const [error, setError] = React.useState<string | null>(null);
const { session } = useSession();
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
React.useEffect(() => {
const loadExerciseData = async () => {
@ -104,7 +110,11 @@ export function ExercisePage() {
return (
<div className="container mx-auto p-6">
<div className="text-center">
<p className="text-red-600 mb-4">{error}</p>
<AdaptiveText
text={error}
isUpperCase={isUpperCase}
className="text-red-600 mb-4"
/>
<button
onClick={() => navigate(-1)}
className="text-purple-600 hover:text-purple-700"
@ -136,7 +146,9 @@ export function ExercisePage() {
case 'word-formation':
return (
<WordFormation
words={exerciseWords.map(w => w.word)}
words={exerciseWords.map(w =>
isUpperCase ? w.word.toUpperCase() : w.word
)}
storyId={storyId as string}
studentId={exerciseData.story.student_id}
/>
@ -170,8 +182,24 @@ export function ExercisePage() {
className="flex items-center gap-2 text-gray-600 hover:text-gray-900 mb-4"
>
<ArrowLeft className="w-5 h-5" />
Voltar para história
<AdaptiveText
text="Voltar para história"
isUpperCase={isUpperCase}
/>
</button>
<div className="flex justify-between items-center">
<AdaptiveTitle
text={`Exercício: ${type?.replace('-', ' ')}`}
isUpperCase={isUpperCase}
className="text-xl font-bold"
/>
<TextCaseToggle
isUpperCase={isUpperCase}
onToggle={toggleUppercase}
isLoading={isLoading}
/>
</div>
</div>
{/* Exercício */}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { ArrowLeft, ArrowRight, Volume2, Share2, ChevronDown, ChevronUp, Loader2, Pause, Play, Download, RefreshCw, Trash2 } from 'lucide-react';
import { ArrowLeft, ArrowRight, Volume2, Share2, ChevronDown, ChevronUp, Loader2, Pause, Play, Download, RefreshCw, Trash2, TextSelect } from 'lucide-react';
import { useParams, useNavigate } from 'react-router-dom';
import { supabase } from '../../lib/supabase';
import { AudioRecorder } from '../../components/story/AudioRecorder';
@ -8,6 +8,12 @@ import { StoryMetrics } from '../../components/story/StoryMetrics';
import { convertWebmToMp3 } from '../../utils/audioConverter';
import * as Dialog from '@radix-ui/react-dialog';
import { ExerciseSuggestions } from '../../components/learning/ExerciseSuggestions';
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
import { AdaptiveText, AdaptiveTitle, AdaptiveParagraph } from '../../components/ui/adaptive-text';
import { useSession } from '../../hooks/useSession';
import { useSyllables } from '../../features/syllables/hooks/useSyllables';
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
interface StoryRecording {
id: string;
@ -36,6 +42,9 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
const [isConverting, setIsConverting] = React.useState(false);
const [mp3Url, setMp3Url] = React.useState<string | null>(null);
const [conversionError, setConversionError] = React.useState<string | null>(null);
const { session } = useSession();
const { isUpperCase } = useUppercasePreference(session?.user?.id);
const { isHighlighted } = useSyllables();
// Verificar suporte ao formato WebM
React.useEffect(() => {
@ -284,9 +293,12 @@ function RecordingHistoryCard({ recording }: { recording: StoryRecording }) {
<div className="mt-4">
<h5 className="text-sm font-medium text-gray-900 mb-2">Transcrição</h5>
<div className="p-4 bg-gray-50 rounded-lg">
<p className="text-sm text-gray-600 whitespace-pre-wrap">
{recording.transcription || 'Transcrição não disponível'}
</p>
<AdaptiveText
text={recording.transcription || 'Transcrição não disponível'}
isUpperCase={isUpperCase}
className="text-sm text-gray-600 whitespace-pre-wrap"
highlightSyllables={isHighlighted}
/>
</div>
</div>
@ -385,6 +397,10 @@ export function StoryPage() {
const [loadingRecordings, setLoadingRecordings] = React.useState(true);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const { session } = useSession();
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
const { isHighlighted, toggleHighlight } = useSyllables();
React.useEffect(() => {
const fetchStory = async () => {
@ -602,10 +618,24 @@ export function StoryPage() {
className="flex items-center gap-2 text-gray-600 hover:text-gray-900"
>
<ArrowLeft className="h-5 w-5" />
Voltar para histórias
<AdaptiveText text="Voltar para histórias" isUpperCase={isUpperCase} />
</button>
<div className="flex items-center gap-4">
<TextCaseToggle
isUpperCase={isUpperCase}
onToggle={toggleUppercase}
isLoading={isLoading}
/>
<button
onClick={toggleHighlight}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-colors text-gray-600 hover:text-gray-900 hover:bg-gray-100 border border-gray-200"
>
<TextSelect className="h-4 w-4" />
<span className="text-sm font-medium">
{isHighlighted ? 'Desativar Sílabas' : 'Ativar Sílabas'}
</span>
</button>
<button
onClick={handleShare}
className="flex items-center gap-2 px-4 py-2 text-gray-600 hover:text-gray-900"
@ -635,12 +665,19 @@ export function StoryPage() {
)}
<div className="p-8">
<h1 className="text-3xl font-bold text-gray-900 mb-6">{story?.title}</h1>
<AdaptiveTitle
text={story?.title || ''}
isUpperCase={isUpperCase}
className="text-3xl font-bold text-gray-900 mb-6"
/>
{/* Texto da página atual */}
<p className="text-xl leading-relaxed text-gray-700 mb-8">
{story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
</p>
<AdaptiveParagraph
text={story?.content?.pages?.[currentPage]?.text || 'Carregando...'}
isUpperCase={isUpperCase}
highlightSyllables={isHighlighted}
className="text-xl leading-relaxed text-gray-700 mb-8"
/>
{/* Gravador de áudio */}
<AudioRecorder

View File

@ -11,16 +11,21 @@ import {
Menu,
X,
ChevronLeft,
ChevronRight
ChevronRight,
} from 'lucide-react';
import { useAuth } from '../../hooks/useAuth';
import * as Dialog from '@radix-ui/react-dialog';
import { TextCaseToggle } from '../../components/ui/text-case-toggle';
import { useUppercasePreference } from '../../hooks/useUppercasePreference';
import { useSession } from '../../hooks/useSession';
export function StudentDashboardLayout() {
const navigate = useNavigate();
const { signOut } = useAuth();
const [isCollapsed, setIsCollapsed] = React.useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
const { session } = useSession();
const { isUpperCase, toggleUppercase, isLoading } = useUppercasePreference(session?.user?.id);
const handleLogout = async () => {
await signOut();
@ -64,7 +69,7 @@ export function StudentDashboardLayout() {
{!isCollapsed && <span>Minhas Histórias</span>}
</NavLink>
<NavLink
{/* <NavLink
to="/aluno/conquistas"
onClick={handleNavigation}
className={({ isActive }) =>
@ -123,7 +128,7 @@ export function StudentDashboardLayout() {
<Trophy className="h-5 w-5" />
{!isCollapsed && <span>Progresso Fônico</span>}
</NavLink>
*/}
<NavLink
to="/aluno/configuracoes"
onClick={handleNavigation}

View File

@ -77,7 +77,7 @@ export function StudentDashboardPage() {
.eq('student_id', session.user.id)
.eq('story_pages.page_number', 1) // Garante que pegamos a primeira página
.order('created_at', { ascending: false })
.limit(3);
.limit(6);
if (error) throw error;
setRecentStories(data || []);
@ -252,10 +252,7 @@ export function StudentDashboardPage() {
{story.cover && (
<div className="relative aspect-video">
<img
src={supabase.storage
.from('story-images')
.getPublicUrl(story.cover.image_url).data.publicUrl +
`?width=400&height=300&quality=80&format=webp`}
src={`${story.cover.image_url}?width=400&height=300&quality=80&format=webp`}
alt={story.title}
className="w-full h-48 object-cover"
loading="lazy"

View File

@ -25,8 +25,14 @@ export function StudentStoriesPage() {
const query = supabase
.from('stories')
.select('*')
.eq('student_id', session.user.id);
.select(`
*,
pages:story_pages (
image_url
)
`)
.eq('student_id', session.user.id)
.order('created_at', { ascending: false });
if (statusFilter !== 'all') {
query.eq('status', statusFilter);
@ -197,16 +203,21 @@ export function StudentStoriesPage() {
className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden cursor-pointer hover:shadow-md transition"
onClick={() => navigate(`/aluno/historias/${story.id}`)}
>
{story.cover && (
{!story.cover?.image_url && (
<div className="bg-gray-100 aspect-video flex items-center justify-center">
<BookOpen className="h-12 w-12 text-gray-400" />
</div>
)}
{story.cover?.image_url && (
<div className="relative aspect-video">
<img
src={supabase.storage
.from('story-images')
.getPublicUrl(story.cover.image_url).data.publicUrl +
`?width=400&height=300&quality=80&format=webp`}
alt={story.title}
src={story.cover.image_url}
alt={`Capa da história: ${story.title}`}
className="w-full h-48 object-cover"
loading="lazy"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
</div>
)}