mirror of
https://github.com/lucasrcsantana/story-generator.git
synced 2025-12-16 13:27:52 +00:00
First Commit
This commit is contained in:
commit
d2b959fe73
3
.bolt/config.json
Normal file
3
.bolt/config.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"template": "bolt-vite-react-ts"
|
||||
}
|
||||
8
.bolt/prompt
Normal file
8
.bolt/prompt
Normal 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
108
.cursorrules
Normal 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
27
.gitignore
vendored
Normal 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
52
README.md
Normal 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
28
eslint.config.js
Normal 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
13
index.html
Normal 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
4051
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
33
package.json
Normal file
33
package.json
Normal 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
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
105
src/App.tsx
Normal file
105
src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
141
src/components/AvatarSelector.tsx
Normal file
141
src/components/AvatarSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
src/components/RegistrationForm.tsx
Normal file
130
src/components/RegistrationForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
src/components/StoryViewer.tsx
Normal file
61
src/components/StoryViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
src/components/ThemeSelector.tsx
Normal file
152
src/components/ThemeSelector.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/components/WelcomePage.tsx
Normal file
65
src/components/WelcomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/auth/LoginForm.tsx
Normal file
98
src/components/auth/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/components/library/StoryLibrary.tsx
Normal file
48
src/components/library/StoryLibrary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/components/story/StoryContent.tsx
Normal file
14
src/components/story/StoryContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/story/StoryControls.tsx
Normal file
55
src/components/story/StoryControls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/story/StoryImage.tsx
Normal file
40
src/components/story/StoryImage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
src/contexts/AuthContext.tsx
Normal file
24
src/contexts/AuthContext.tsx
Normal 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
74
src/hooks/useAuth.ts
Normal 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
207
src/hooks/useDatabase.ts
Normal 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
25
src/hooks/useShare.ts
Normal 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
3
src/index.css
Normal file
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
6
src/lib/supabase.ts
Normal file
6
src/lib/supabase.ts
Normal 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
10
src/main.tsx
Normal 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
13
src/types/auth.ts
Normal 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
106
src/types/database.ts
Normal 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
28
src/types/index.ts
Normal 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
1
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
8
tailwind.config.js
Normal file
8
tailwind.config.js
Normal 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
24
tsconfig.app.json
Normal 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
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
22
tsconfig.node.json
Normal file
22
tsconfig.node.json
Normal 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
10
vite.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user