First Commit

This commit is contained in:
Lucas Santana 2024-12-19 17:13:10 -03:00
commit d2b959fe73
37 changed files with 5806 additions and 0 deletions

3
.bolt/config.json Normal file
View File

@ -0,0 +1,3 @@
{
"template": "bolt-vite-react-ts"
}

8
.bolt/prompt Normal file
View File

@ -0,0 +1,8 @@
For all designs I ask you to make, have them be beautiful, not cookie cutter. Make webpages that are fully featured and worthy for production.
By default, this template supports JSX syntax with Tailwind CSS classes, React hooks, and Lucide React for icons. Do not install other packages for UI themes, icons, etc unless absolutely necessary or I request them.
Use icons from lucide-react for logos.
Use stock photos from unsplash where appropriate, only valid URLs you know exist. Do not download the images, only link to them in image tags.

108
.cursorrules Normal file
View File

@ -0,0 +1,108 @@
{
"rules": [
{
"name": "Padrões de Código",
"description": "Regras gerais para manter consistência no código",
"patterns": [
{
"id": "naming-conventions",
"pattern": "^[a-z][a-zA-Z0-9]*$",
"message": "Use camelCase para nomes de variáveis e funções"
},
{
"id": "component-naming",
"pattern": "^[A-Z][a-zA-Z0-9]*$",
"message": "Componentes React devem começar com letra maiúscula"
}
]
},
{
"name": "Segurança",
"description": "Regras para garantir segurança da aplicação",
"patterns": [
{
"id": "no-sensitive-data",
"pattern": "(password|senha|token|key|secret)",
"message": "Não exponha dados sensíveis no código"
},
{
"id": "child-safety",
"pattern": "(idade|age).*(>12|<6)",
"message": "Verifique restrições de idade (6-12 anos)"
}
]
},
{
"name": "Acessibilidade",
"description": "Regras para garantir acessibilidade",
"patterns": [
{
"id": "alt-text",
"pattern": "<img(?!.*alt=)[^>]*>",
"message": "Imagens devem ter texto alternativo (alt)"
},
{
"id": "aria-labels",
"pattern": "<button(?!.*aria-label)[^>]*>",
"message": "Botões devem ter aria-label quando não têm texto"
}
]
},
{
"name": "Performance",
"description": "Regras para otimização de performance",
"patterns": [
{
"id": "large-images",
"pattern": "unsplash.com/.*w=(\\d{4,})",
"message": "Evite imagens muito grandes (max 1200px)"
},
{
"id": "memo-check",
"pattern": "React.memo\\(",
"message": "Verifique se o uso de memo é necessário"
}
]
},
{
"name": "Estilo",
"description": "Regras de estilo e formatação",
"patterns": [
{
"id": "tailwind-classes",
"pattern": "className=\"[^\"]{150,}\"",
"message": "Considere extrair classes Tailwind longas para componentes"
},
{
"id": "color-consistency",
"pattern": "text-(purple|green|orange|blue|yellow)",
"message": "Use cores consistentes com a paleta definida"
}
]
},
{
"name": "Conteúdo",
"description": "Regras para conteúdo infantil",
"patterns": [
{
"id": "child-friendly",
"pattern": "(violência|morte|guerra|violento)",
"message": "Evite conteúdo inadequado para crianças"
},
{
"id": "educational-content",
"pattern": "(educativo|educacional|aprendizado|ensino)",
"message": "Priorize conteúdo educacional e construtivo"
}
]
}
],
"ignoreFiles": [
"node_modules/**",
"dist/**",
"build/**",
".git/**",
"*.test.*",
"*.spec.*"
]
}

27
.gitignore vendored Normal file
View File

@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
.env
.env.local

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# Histórias Mágicas 🌟
Uma plataforma educacional interativa que oferece histórias personalizadas para crianças entre 6 e 12 anos, com foco na cultura brasileira e educação.
## 🎯 Sobre o Projeto
Histórias Mágicas é uma aplicação web desenvolvida em React que permite que crianças explorem histórias educativas de forma interativa e personalizada. O projeto tem como objetivo:
- Promover a educação através de narrativas envolventes
- Valorizar a diversidade cultural brasileira
- Incentivar a consciência ambiental
- Oferecer uma experiência de aprendizado segura e divertida
## ✨ Funcionalidades
- 📚 Biblioteca de histórias interativas
- 👤 Personalização de avatares (culturas baiana e amazônica)
- 🎨 Temas educacionais diversos
- 🔊 Narração de histórias
- 📱 Design responsivo
- 🔒 Ambiente seguro e protegido
## 🛠️ Tecnologias Utilizadas
- React 18
- TypeScript
- Tailwind CSS
- Lucide React (ícones)
- Vite
## 🚀 Como Executar
1. Clone o repositório:
## 🚀 Deploy
### Opções Recomendadas
#### 1. Vercel (Recomendação Principal)
- Ideal para aplicações React/Next.js
- Deploy automático integrado com GitHub
- SSL gratuito
- CDN global
- Excelente performance
- Plano gratuito generoso
#### 2. Netlify
- Também oferece deploy automático
- Funções serverless incluídas
- SSL gratuito
- CDN global
- Interface amigável

28
eslint.config.js Normal file
View File

@ -0,0 +1,28 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}
);

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React + TS</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4051
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

33
package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "vite-react-typescript-starter",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@eslint/js": "^9.9.1",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.4.18",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
"eslint-plugin-react-refresh": "^0.4.11",
"globals": "^15.9.0",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"typescript": "^5.5.3",
"typescript-eslint": "^8.3.0",
"vite": "^5.4.2"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

105
src/App.tsx Normal file
View File

@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { WelcomePage } from './components/WelcomePage';
import { LoginForm } from './components/auth/LoginForm';
import { RegistrationForm } from './components/RegistrationForm';
import { AvatarSelector } from './components/AvatarSelector';
import { ThemeSelector } from './components/ThemeSelector';
import { StoryViewer } from './components/StoryViewer';
import { StoryLibrary } from './components/library/StoryLibrary';
import { AuthUser, SavedStory } from './types/auth';
import { User, Theme } from './types';
import { AuthProvider } from './contexts/AuthContext'
type AppStep =
| 'welcome'
| 'login'
| 'register'
| 'avatar'
| 'theme'
| 'story'
| 'library';
export function App() {
const [step, setStep] = useState<AppStep>('welcome');
const [user, setUser] = useState<User | null>(null);
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
const [selectedTheme, setSelectedTheme] = useState<Theme | null>(null);
const [savedStories] = useState<SavedStory[]>([
{
id: '1',
title: 'A Aventura na Floresta Amazônica',
theme: 'Natureza e Meio Ambiente',
createdAt: '2024-03-15T10:00:00Z',
lastReadPage: 2,
},
]);
const handleLogin = (email: string, password: string) => {
// In production: Implement actual authentication
setAuthUser({ id: '1', email, name: 'Usuário' });
setStep('library');
};
const handleRegistrationComplete = (userData: User) => {
setUser(userData);
setStep('avatar');
};
const handleAvatarComplete = (avatarId: string) => {
if (user) {
setUser({ ...user, avatarId });
setStep('theme');
}
};
const handleThemeSelect = (theme: Theme) => {
setSelectedTheme(theme);
setStep('story');
};
const handleStorySelect = (storyId: string) => {
// In production: Load the selected story
const story = savedStories.find(s => s.id === storyId);
if (story) {
// Navigate to the story viewer
setStep('story');
}
};
return (
<AuthProvider>
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
{step === 'welcome' && (
<WelcomePage
onLoginClick={() => setStep('login')}
onRegisterClick={() => setStep('register')}
/>
)}
{step === 'login' && (
<div className="min-h-screen flex items-center justify-center p-6">
<LoginForm
onLogin={handleLogin}
onRegisterClick={() => setStep('register')}
/>
</div>
)}
{step === 'register' && (
<RegistrationForm onComplete={handleRegistrationComplete} />
)}
{step === 'avatar' && user && (
<AvatarSelector user={user} onComplete={handleAvatarComplete} />
)}
{step === 'theme' && <ThemeSelector onSelect={handleThemeSelect} />}
{step === 'story' && user && selectedTheme && (
<StoryViewer theme={selectedTheme} user={user} />
)}
{step === 'library' && authUser && (
<StoryLibrary
stories={savedStories}
onStorySelect={handleStorySelect}
/>
)}
</div>
</AuthProvider>
);
}

View File

@ -0,0 +1,141 @@
import React from 'react';
import { User, Avatar } from '../types';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const AVATARS: Avatar[] = [
// Bahia Culture
{
id: 'capoeira',
name: 'Capoeirista',
category: 'bahia',
description: 'Um jovem capoeirista cheio de energia',
baseColor: '#FCD34D',
},
{
id: 'baiana',
name: 'Baiana',
category: 'bahia',
description: 'Uma alegre baiana do acarajé',
baseColor: '#F87171',
},
{
id: 'pescador',
name: 'Pescador',
category: 'bahia',
description: 'Um pescador tradicional da Bahia',
baseColor: '#60A5FA',
},
{
id: 'sambista',
name: 'Sambista',
category: 'bahia',
description: 'Um animado sambista',
baseColor: '#34D399',
},
// Amazon Culture
{
id: 'indigenous',
name: 'Indígena',
category: 'amazon',
description: 'Um guardião da floresta',
baseColor: '#A78BFA',
},
{
id: 'ribeirinho',
name: 'Ribeirinho',
category: 'amazon',
description: 'Um conhecedor dos rios',
baseColor: '#4ADE80',
},
{
id: 'seringueira',
name: 'Seringueira',
category: 'amazon',
description: 'Uma protetora da floresta',
baseColor: '#F472B6',
},
{
id: 'artesao',
name: 'Artesão',
category: 'amazon',
description: 'Um artesão da Amazônia',
baseColor: '#FB923C',
},
];
interface Props {
user: User;
onComplete: (avatarId: string) => void;
}
export function AvatarSelector({ user, onComplete }: Props) {
const [selectedAvatar, setSelectedAvatar] = React.useState(AVATARS[0]);
const [category, setCategory] = React.useState<'bahia' | 'amazon'>('bahia');
const filteredAvatars = AVATARS.filter((avatar) => avatar.category === category);
return (
<div className="min-h-screen bg-gradient-to-b from-green-100 to-blue-100 p-6">
<div className="max-w-4xl mx-auto bg-white rounded-2xl shadow-xl p-8">
<h1 className="text-3xl font-bold text-center text-green-600 mb-8">
Escolha seu Personagem, {user.name}!
</h1>
<div className="flex justify-center gap-4 mb-8">
<button
onClick={() => setCategory('bahia')}
className={`px-6 py-3 rounded-full text-lg font-medium transition ${
category === 'bahia'
? 'bg-green-600 text-white'
: 'bg-green-100 text-green-600 hover:bg-green-200'
}`}
>
Cultura Baiana
</button>
<button
onClick={() => setCategory('amazon')}
className={`px-6 py-3 rounded-full text-lg font-medium transition ${
category === 'amazon'
? 'bg-green-600 text-white'
: 'bg-green-100 text-green-600 hover:bg-green-200'
}`}
>
Cultura Amazônica
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{filteredAvatars.map((avatar) => (
<button
key={avatar.id}
onClick={() => setSelectedAvatar(avatar)}
className={`p-4 rounded-xl transition ${
selectedAvatar.id === avatar.id
? 'bg-green-100 border-2 border-green-500'
: 'bg-gray-50 border-2 border-transparent hover:bg-green-50'
}`}
>
<div
className="w-32 h-32 mx-auto rounded-full mb-4"
style={{ backgroundColor: avatar.baseColor }}
/>
<h3 className="text-lg font-semibold text-gray-800">
{avatar.name}
</h3>
<p className="text-sm text-gray-600">{avatar.description}</p>
</button>
))}
</div>
<div className="mt-8 flex justify-center">
<button
onClick={() => onComplete(selectedAvatar.id)}
className="px-8 py-3 text-lg font-semibold text-white bg-green-600 rounded-xl hover:bg-green-700 transition"
>
Continuar
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { User } from '../types';
import { Heart, User2, Info } from 'lucide-react';
interface Props {
onComplete: (user: User) => void;
}
export function RegistrationForm({ onComplete }: Props) {
const [name, setName] = useState('');
const [age, setAge] = useState(8);
const [gender, setGender] = useState<'boy' | 'girl' | 'other'>('other');
const [showPrivacy, setShowPrivacy] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onComplete({
name: name.trim(),
age,
gender,
avatarId: '',
});
}
};
return (
<div className="min-h-screen bg-gradient-to-b from-blue-100 to-purple-100 p-6">
<div className="max-w-md mx-auto bg-white rounded-2xl shadow-xl p-8">
<h1 className="text-3xl font-bold text-center text-purple-600 mb-8">
Bem-vindo à Aventura!
</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block text-lg font-medium text-gray-700 mb-2">
Como você se chama?
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full px-4 py-3 rounded-lg border-2 border-purple-200 focus:border-purple-500 focus:ring focus:ring-purple-200 transition"
required
maxLength={20}
placeholder="Seu primeiro nome"
/>
</div>
<div>
<label className="block text-lg font-medium text-gray-700 mb-2">
Quantos anos você tem?
</label>
<input
type="range"
min="6"
max="12"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
className="w-full h-2 bg-purple-200 rounded-lg appearance-none cursor-pointer"
/>
<div className="text-center text-2xl font-bold text-purple-600 mt-2">
{age} anos
</div>
</div>
<div>
<label className="block text-lg font-medium text-gray-700 mb-4">
Como você se identifica?
</label>
<div className="flex justify-center gap-4">
{[
{ value: 'boy', label: 'Menino', icon: User2 },
{ value: 'girl', label: 'Menina', icon: Heart },
{ value: 'other', label: 'Outro', icon: User2 },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => setGender(option.value as any)}
className={`flex flex-col items-center p-4 rounded-xl transition ${
gender === option.value
? 'bg-purple-100 border-2 border-purple-500'
: 'bg-gray-50 border-2 border-transparent hover:bg-purple-50'
}`}
>
<option.icon
className={`w-8 h-8 ${
gender === option.value ? 'text-purple-600' : 'text-gray-600'
}`}
/>
<span className="mt-2 text-sm font-medium">{option.label}</span>
</button>
))}
</div>
</div>
<div className="mt-6">
<button
type="button"
onClick={() => setShowPrivacy(!showPrivacy)}
className="flex items-center text-sm text-purple-600 hover:text-purple-700"
>
<Info className="w-4 h-4 mr-1" />
Política de Privacidade
</button>
{showPrivacy && (
<div className="mt-2 p-4 bg-purple-50 rounded-lg text-sm">
<p>
Olá! Nós guardamos apenas seu primeiro nome para tornar suas
histórias mais divertidas. Não compartilhamos suas informações
com ninguém. Seus pais podem solicitar a exclusão dos dados a
qualquer momento.
</p>
</div>
)}
</div>
<button
type="submit"
disabled={!name.trim()}
className="w-full py-3 px-6 text-lg font-semibold text-white bg-purple-600 rounded-xl hover:bg-purple-700 transition disabled:opacity-50"
>
Começar Aventura!
</button>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { Theme, User } from '../types';
import { StoryContent } from './story/StoryContent';
import { StoryImage } from './story/StoryImage';
import { StoryControls } from './story/StoryControls';
interface Props {
theme: Theme;
user: User;
}
export function StoryViewer({ theme, user }: Props) {
const [isPlaying, setIsPlaying] = useState(false);
const [currentPage, setCurrentPage] = useState(0);
const story = {
title: `${user.name} e a Aventura ${theme.title}`,
pages: [
{
text: `Era uma vez, em um lugar muito especial do Brasil, vivia ${user.name}, uma criança muito curiosa de ${user.age} anos. Um dia, enquanto explorava ${theme.category === 'environment' ? 'a natureza' : 'sua cultura'}, algo mágico aconteceu...`,
image: `https://images.unsplash.com/photo-1472162072942-cd5147eb3902?auto=format&fit=crop&q=80&w=800&h=600`,
},
{
text: "A jornada estava apenas começando, e muitas descobertas incríveis aguardavam...",
image: `https://images.unsplash.com/photo-1519681393784-d120267933ba?auto=format&fit=crop&q=80&w=800&h=600`,
},
],
};
const toggleAudio = () => {
setIsPlaying(!isPlaying);
// In production: implement actual audio playback
};
return (
<div className="min-h-screen bg-gradient-to-b from-indigo-100 to-purple-100 p-6">
<div className="max-w-4xl mx-auto bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="p-6 bg-indigo-600 text-white">
<h1 className="text-3xl font-bold text-center">{story.title}</h1>
</div>
<StoryImage
src={story.pages[currentPage].image}
onNext={() => setCurrentPage(currentPage + 1)}
onPrev={() => setCurrentPage(currentPage - 1)}
hasNext={currentPage < story.pages.length - 1}
hasPrev={currentPage > 0}
/>
<div className="p-8">
<StoryContent page={story.pages[currentPage]} />
<StoryControls
isPlaying={isPlaying}
onToggleAudio={toggleAudio}
title={story.title}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,152 @@
import React from 'react';
import { Theme } from '../types';
import {
Leaf,
Users,
PartyPopper,
History,
Droplets,
Dog,
Recycle,
} from 'lucide-react';
const THEMES: Theme[] = [
{
id: 'nature',
title: 'Natureza e Meio Ambiente',
description: 'Explore as maravilhas do nosso planeta',
icon: 'Leaf',
sdsGoal: 13,
category: 'environment',
},
{
id: 'indigenous',
title: 'Povos Originários do Brasil',
description: 'Conheça as culturas indígenas',
icon: 'Users',
sdsGoal: 10,
category: 'culture',
},
{
id: 'traditions',
title: 'Festas e Tradições Brasileiras',
description: 'Celebre nossa cultura',
icon: 'PartyPopper',
sdsGoal: 11,
category: 'culture',
},
{
id: 'quilombola',
title: 'Histórias Quilombolas',
description: 'Descubra nossa história',
icon: 'History',
sdsGoal: 10,
category: 'culture',
},
{
id: 'water',
title: 'Água é Vida',
description: 'Aprenda sobre esse recurso precioso',
icon: 'Droplets',
sdsGoal: 6,
category: 'environment',
},
{
id: 'animals',
title: 'Animais Brasileiros',
description: 'Conheça nossa fauna',
icon: 'Dog',
sdsGoal: 15,
category: 'nature',
},
{
id: 'sustainable',
title: 'Vivendo Sustentável',
description: 'Cuide do nosso futuro',
icon: 'Recycle',
sdsGoal: 12,
category: 'environment',
},
];
const IconMap = {
Leaf,
Users,
PartyPopper,
History,
Droplets,
Dog,
Recycle,
};
interface Props {
onSelect: (theme: Theme) => void;
}
export function ThemeSelector({ onSelect }: Props) {
const [selectedCategory, setSelectedCategory] = React.useState('all');
const filteredThemes =
selectedCategory === 'all'
? THEMES
: THEMES.filter((theme) => theme.category === selectedCategory);
return (
<div className="min-h-screen bg-gradient-to-b from-yellow-100 to-orange-100 p-6">
<div className="max-w-6xl mx-auto">
<h1 className="text-4xl font-bold text-center text-orange-600 mb-8">
Escolha sua Aventura!
</h1>
<div className="flex justify-center gap-4 mb-8">
{[
{ id: 'all', label: 'Todos' },
{ id: 'environment', label: 'Meio Ambiente' },
{ id: 'culture', label: 'Cultura' },
{ id: 'nature', label: 'Natureza' },
].map((category) => (
<button
key={category.id}
onClick={() => setSelectedCategory(category.id)}
className={`px-6 py-3 rounded-full text-lg font-medium transition ${
selectedCategory === category.id
? 'bg-orange-600 text-white'
: 'bg-orange-100 text-orange-600 hover:bg-orange-200'
}`}
>
{category.label}
</button>
))}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredThemes.map((theme) => {
const Icon = IconMap[theme.icon as keyof typeof IconMap];
return (
<button
key={theme.id}
onClick={() => onSelect(theme)}
className="bg-white p-6 rounded-xl shadow-lg hover:shadow-xl transition group"
>
<div className="flex items-center gap-4 mb-4">
<div className="p-3 rounded-full bg-orange-100 group-hover:bg-orange-200 transition">
<Icon className="w-8 h-8 text-orange-600" />
</div>
<div className="flex-1">
<h3 className="text-xl font-semibold text-gray-800">
{theme.title}
</h3>
<p className="text-sm text-gray-600">{theme.description}</p>
</div>
</div>
<div className="text-sm text-orange-600 font-medium">
ODS {theme.sdsGoal}
</div>
</button>
);
})}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,65 @@
import React from 'react';
import { Book, Sparkles, Shield } from 'lucide-react';
interface Props {
onLoginClick: () => void;
onRegisterClick: () => void;
}
export function WelcomePage({ onLoginClick, onRegisterClick }: Props) {
return (
<div className="min-h-screen bg-gradient-to-b from-purple-100 to-blue-100">
<div className="max-w-6xl mx-auto px-6 py-16">
<div className="text-center mb-16">
<h1 className="text-5xl font-bold text-purple-600 mb-4">
Histórias Mágicas
</h1>
<p className="text-xl text-gray-600">
Embarque em uma jornada de aprendizado e diversão!
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-16">
<div className="bg-white p-6 rounded-xl shadow-lg text-center">
<Book className="w-12 h-12 text-purple-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Histórias Educativas</h2>
<p className="text-gray-600">
Aprenda sobre cultura, meio ambiente e muito mais
</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-lg text-center">
<Sparkles className="w-12 h-12 text-purple-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Personalize sua Jornada</h2>
<p className="text-gray-600">
Crie seu próprio personagem e escolha suas aventuras
</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-lg text-center">
<Shield className="w-12 h-12 text-purple-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Ambiente Seguro</h2>
<p className="text-gray-600">
Conteúdo adequado e proteção de dados
</p>
</div>
</div>
<div className="flex justify-center gap-4">
<button
onClick={onLoginClick}
className="px-8 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition"
>
Entrar
</button>
<button
onClick={onRegisterClick}
className="px-8 py-3 bg-white text-purple-600 rounded-lg hover:bg-purple-50 transition"
>
Criar Conta
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,98 @@
import React, { useState } from 'react';
import { LogIn } from 'lucide-react';
import { useAuth } from '../../hooks/useAuth';
interface Props {
onLogin: (email: string, password: string) => void;
onRegisterClick: () => void;
}
export function LoginForm({ onLogin, onRegisterClick }: Props) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const { signIn } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
try {
const { user } = await signIn(email, password);
if (user) {
onLogin(email, password);
}
} catch (err) {
setError('Erro ao fazer login. Verifique suas credenciais.');
}
};
return (
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="text-3xl font-bold text-center text-purple-600">
Bem-vindo de volta!
</h2>
<p className="mt-2 text-center text-gray-600">
Continue sua jornada de histórias mágicas
</p>
</div>
{error && (
<div className="bg-red-50 text-red-600 p-3 rounded-lg text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
Email
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Senha
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:border-purple-500 focus:ring-purple-500"
/>
</div>
</div>
<button
type="submit"
className="w-full flex justify-center items-center gap-2 py-3 px-4 border border-transparent rounded-lg shadow-sm text-white bg-purple-600 hover:bg-purple-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-purple-500"
>
<LogIn className="w-5 h-5" />
Entrar
</button>
<div className="text-center">
<button
type="button"
onClick={onRegisterClick}
className="text-purple-600 hover:text-purple-500"
>
Criar uma nova conta
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,48 @@
import React from 'react';
import { Book, Clock } from 'lucide-react';
import { SavedStory } from '../../types/auth';
interface Props {
stories: SavedStory[];
onStorySelect: (storyId: string) => void;
}
export function StoryLibrary({ stories, onStorySelect }: Props) {
return (
<div className="max-w-6xl mx-auto p-6">
<h1 className="text-3xl font-bold text-purple-600 mb-8">
Minha Biblioteca de Histórias
</h1>
{stories.length === 0 ? (
<div className="text-center py-12">
<Book className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600">
Você ainda não tem histórias salvas.
<br />
Comece uma nova aventura agora!
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{stories.map((story) => (
<button
key={story.id}
onClick={() => onStorySelect(story.id)}
className="bg-white p-6 rounded-xl shadow-lg hover:shadow-xl transition text-left"
>
<h3 className="text-xl font-semibold text-gray-800 mb-2">
{story.title}
</h3>
<p className="text-gray-600 mb-4">Tema: {story.theme}</p>
<div className="flex items-center text-sm text-gray-500">
<Clock className="w-4 h-4 mr-1" />
{new Date(story.createdAt).toLocaleDateString()}
</div>
</button>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,14 @@
import React from 'react';
import { StoryPage } from '../../types';
interface Props {
page: StoryPage;
}
export function StoryContent({ page }: Props) {
return (
<p className="text-xl leading-relaxed text-gray-700 mb-8">
{page.text}
</p>
);
}

View File

@ -0,0 +1,55 @@
import React from 'react';
import { Share2, Download, Volume2, VolumeX } from 'lucide-react';
import { useShare } from '../../hooks/useShare';
interface Props {
isPlaying: boolean;
onToggleAudio: () => void;
title: string;
}
export function StoryControls({ isPlaying, onToggleAudio, title }: Props) {
const { share, isShareSupported } = useShare();
const handleShare = async () => {
await share({
title,
text: 'Confira esta história incrível!',
url: window.location.href,
});
};
return (
<div className="flex justify-center gap-4">
<button
onClick={onToggleAudio}
className="flex items-center gap-2 px-6 py-3 bg-indigo-100 text-indigo-600 rounded-full hover:bg-indigo-200 transition"
>
{isPlaying ? (
<VolumeX className="w-5 h-5" />
) : (
<Volume2 className="w-5 h-5" />
)}
{isPlaying ? 'Pausar Áudio' : 'Ouvir História'}
</button>
{isShareSupported && (
<button
onClick={handleShare}
className="flex items-center gap-2 px-6 py-3 bg-indigo-100 text-indigo-600 rounded-full hover:bg-indigo-200 transition"
>
<Share2 className="w-5 h-5" />
Compartilhar
</button>
)}
<button
onClick={() => {/* Implement PDF download */}}
className="flex items-center gap-2 px-6 py-3 bg-indigo-100 text-indigo-600 rounded-full hover:bg-indigo-200 transition"
>
<Download className="w-5 h-5" />
Salvar PDF
</button>
</div>
);
}

View File

@ -0,0 +1,40 @@
import React from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
interface Props {
src: string;
onNext: () => void;
onPrev: () => void;
hasNext: boolean;
hasPrev: boolean;
}
export function StoryImage({ src, onNext, onPrev, hasNext, hasPrev }: Props) {
return (
<div className="relative aspect-video">
<img
src={src}
alt="Ilustração da história"
className="w-full h-full object-cover"
/>
{hasPrev && (
<button
onClick={onPrev}
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/80 rounded-full hover:bg-white transition"
>
<ChevronLeft className="w-6 h-6 text-indigo-600" />
</button>
)}
{hasNext && (
<button
onClick={onNext}
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/80 rounded-full hover:bg-white transition"
>
<ChevronRight className="w-6 h-6 text-indigo-600" />
</button>
)}
</div>
);
}

View File

@ -0,0 +1,24 @@
import { createContext, useContext, ReactNode } from 'react'
import { useAuth } from '../hooks/useAuth'
type AuthContextType = ReturnType<typeof useAuth>
const AuthContext = createContext<AuthContextType | undefined>(undefined)
export function AuthProvider({ children }: { children: ReactNode }) {
const auth = useAuth()
return (
<AuthContext.Provider value={auth}>
{children}
</AuthContext.Provider>
)
}
export function useAuthContext() {
const context = useContext(AuthContext)
if (context === undefined) {
throw new Error('useAuthContext deve ser usado dentro de um AuthProvider')
}
return context
}

74
src/hooks/useAuth.ts Normal file
View File

@ -0,0 +1,74 @@
import { useState, useEffect } from 'react'
import { supabase } from '../lib/supabase'
import { User } from '@supabase/supabase-js'
export function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
// Verificar sessão atual
supabase.auth.getSession().then(({ data: { session } }) => {
setUser(session?.user ?? null)
setLoading(false)
})
// Escutar mudanças de autenticação
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
setUser(session?.user ?? null)
})
return () => subscription.unsubscribe()
}, [])
const signIn = async (email: string, password: string) => {
try {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password
})
if (error) throw error
return data
} catch (error) {
console.error('Erro ao fazer login:', error)
throw error
}
}
const signUp = async (email: string, password: string) => {
try {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
emailRedirectTo: `${window.location.origin}/auth/callback`
}
})
if (error) throw error
return data
} catch (error) {
console.error('Erro ao criar conta:', error)
throw error
}
}
const signOut = async () => {
try {
const { error } = await supabase.auth.signOut()
if (error) throw error
} catch (error) {
console.error('Erro ao fazer logout:', error)
throw error
}
}
return {
user,
loading,
signIn,
signUp,
signOut
}
}

207
src/hooks/useDatabase.ts Normal file
View File

@ -0,0 +1,207 @@
import { useState } from 'react';
import { supabase } from '../lib/supabase';
import { School, Teacher, Class, Student, TeacherClass, Story, StoryPage } from '../types/database';
export function useDatabase() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Funções para escolas
const getSchools = async () => {
try {
setLoading(true);
const { data, error } = await supabase
.from('schools')
.select('*');
if (error) throw error;
return data;
} catch (err) {
setError('Erro ao buscar escolas');
console.error(err);
return null;
} finally {
setLoading(false);
}
};
// Funções para professores
const getTeachersBySchool = async (schoolId: string) => {
try {
setLoading(true);
const { data, error } = await supabase
.from('teachers')
.select('*')
.eq('school_id', schoolId);
if (error) throw error;
return data;
} catch (err) {
setError('Erro ao buscar professores');
console.error(err);
return null;
} finally {
setLoading(false);
}
};
// Funções para turmas
const getClassesBySchool = async (schoolId: string) => {
try {
setLoading(true);
const { data, error } = await supabase
.from('classes')
.select(`
*,
students (*)
`)
.eq('school_id', schoolId);
if (error) throw error;
return data;
} catch (err) {
setError('Erro ao buscar turmas');
console.error(err);
return null;
} finally {
setLoading(false);
}
};
// Funções para alunos
const getStudentsByClass = async (classId: string) => {
try {
setLoading(true);
const { data, error } = await supabase
.from('students')
.select('*')
.eq('class_id', classId);
if (error) throw error;
return data;
} catch (err) {
setError('Erro ao buscar alunos');
console.error(err);
return null;
} finally {
setLoading(false);
}
};
// Funções para histórias
const getStoriesByStudent = async (studentId: string) => {
try {
setLoading(true);
const { data, error } = await supabase
.from('stories')
.select('*')
.eq('student_id', studentId)
.order('created_at', { ascending: false });
if (error) throw error;
return data;
} catch (err) {
setError('Erro ao buscar histórias');
console.error(err);
return null;
} finally {
setLoading(false);
}
};
const createStory = async (studentId: string, story: Omit<Story, 'id' | 'created_at' | 'updated_at'>) => {
try {
setLoading(true);
const { data, error } = await supabase
.from('stories')
.insert([
{
student_id: studentId,
title: story.title,
theme: story.theme,
content: story.content,
status: story.status
}
])
.select()
.single();
if (error) throw error;
return data;
} catch (err) {
setError('Erro ao criar história');
console.error(err);
return null;
} finally {
setLoading(false);
}
};
const updateStory = async (storyId: string, updates: Partial<Story>) => {
try {
setLoading(true);
const { data, error } = await supabase
.from('stories')
.update(updates)
.eq('id', storyId)
.select()
.single();
if (error) throw error;
return data;
} catch (err) {
setError('Erro ao atualizar história');
console.error(err);
return null;
} finally {
setLoading(false);
}
};
const addPageToStory = async (storyId: string, page: StoryPage) => {
try {
setLoading(true);
const { data: story, error: fetchError } = await supabase
.from('stories')
.select('content')
.eq('id', storyId)
.single();
if (fetchError) throw fetchError;
const updatedContent = {
...story.content,
pages: [...story.content.pages, page]
};
const { data, error } = await supabase
.from('stories')
.update({ content: updatedContent })
.eq('id', storyId)
.select()
.single();
if (error) throw error;
return data;
} catch (err) {
setError('Erro ao adicionar página');
console.error(err);
return null;
} finally {
setLoading(false);
}
};
return {
loading,
error,
getSchools,
getTeachersBySchool,
getClassesBySchool,
getStudentsByClass,
getStoriesByStudent,
createStory,
updateStory,
addPageToStory
};
}

25
src/hooks/useShare.ts Normal file
View File

@ -0,0 +1,25 @@
interface ShareData {
title: string;
text: string;
url: string;
}
export function useShare() {
const isShareSupported = typeof navigator !== 'undefined' && !!navigator.share;
const share = async (data: ShareData) => {
if (!isShareSupported) {
return;
}
try {
await navigator.share(data);
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Error sharing:', error);
}
}
};
return { share, isShareSupported };
}

3
src/index.css Normal file
View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

6
src/lib/supabase.ts Normal file
View File

@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = 'https://bsjlbnyslxzsdwxvkaap.supabase.co'
const supabaseAnonKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJzamxibnlzbHh6c2R3eHZrYWFwIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MzQ2MzUzNzYsImV4cCI6MjA1MDIxMTM3Nn0.ygEUrAu2ZnCkfgS4-k4Puvk7ywkn3U7Bnzh7BSOQWFo'
export const supabase = createClient(supabaseUrl, supabaseAnonKey)

10
src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

13
src/types/auth.ts Normal file
View File

@ -0,0 +1,13 @@
export interface AuthUser {
id: string;
email: string;
name: string;
}
export interface SavedStory {
id: string;
title: string;
theme: string;
createdAt: string;
lastReadPage: number;
}

106
src/types/database.ts Normal file
View File

@ -0,0 +1,106 @@
export interface School {
id: string;
name: string;
address?: string;
phone?: string;
email?: string;
created_at: string;
updated_at: string;
}
export interface Teacher {
id: string;
school_id: string;
name: string;
email: string;
phone?: string;
subject?: string;
created_at: string;
updated_at: string;
}
export interface Class {
id: string;
school_id: string;
name: string;
grade: string;
year: number;
period?: string;
created_at: string;
updated_at: string;
}
export interface Student {
id: string;
class_id: string;
name: string;
email: string;
birth_date?: string;
guardian_name?: string;
guardian_phone?: string;
guardian_email?: string;
created_at: string;
updated_at: string;
}
export interface TeacherClass {
id: string;
teacher_id: string;
class_id: string;
created_at: string;
}
// Tipos para relacionamentos
export interface ClassWithTeachers extends Class {
teachers: Teacher[];
}
export interface TeacherWithClasses extends Teacher {
classes: Class[];
}
export interface ClassWithStudents extends Class {
students: Student[];
}
export interface SchoolWithRelations extends School {
teachers: Teacher[];
classes: ClassWithStudents[];
}
export interface StoryPage {
text: string;
image?: string;
audio?: string;
}
export interface Story {
id: string;
student_id: string;
title: string;
theme: string;
content: {
pages: StoryPage[];
currentPage?: number;
lastModified?: string;
};
status: 'draft' | 'published' | 'archived';
created_at: string;
updated_at: string;
}
// Atualizando a interface Student para incluir histórias
export interface StudentWithStories extends Student {
stories: Story[];
}
// Atualizando ClassWithStudents para incluir histórias dos alunos
export interface ClassWithStudentsAndStories extends Class {
students: StudentWithStories[];
}
// Atualizando SchoolWithRelations
export interface SchoolWithRelations extends School {
teachers: Teacher[];
classes: ClassWithStudentsAndStories[];
}

28
src/types/index.ts Normal file
View File

@ -0,0 +1,28 @@
export interface User {
name: string;
age: number;
gender: 'boy' | 'girl' | 'other';
avatarId: string;
}
export interface Avatar {
id: string;
name: string;
category: 'bahia' | 'amazon';
description: string;
baseColor: string;
}
export interface Theme {
id: string;
title: string;
description: string;
icon: string;
sdsGoal: number;
category: string;
}
export interface StoryPage {
text: string;
image: string;
}

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

8
tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

24
tsconfig.app.json Normal file
View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

22
tsconfig.node.json Normal file
View File

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

10
vite.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
optimizeDeps: {
exclude: ['lucide-react'],
},
});