├── public ├── _redirects ├── favicon.ico ├── apple-touch-icon.png ├── apple-touch-icon-57x57.png ├── apple-touch-icon-72x72.png ├── apple-touch-icon-76x76.png ├── apple-touch-icon-114x114.png ├── apple-touch-icon-120x120.png ├── apple-touch-icon-144x144.png ├── apple-touch-icon-152x152.png ├── apple-touch-icon-180x180.png ├── .htaccess ├── version.json ├── robots.txt ├── manifest.json ├── sw.js └── sitemap.xml ├── src ├── vite-env.d.ts ├── core │ ├── services │ │ ├── theme │ │ │ └── index.ts │ │ ├── version │ │ │ ├── index.ts │ │ │ └── versionControl.ts │ │ ├── notification │ │ │ ├── index.ts │ │ │ └── notificationService.ts │ │ ├── storage │ │ │ ├── index.ts │ │ │ └── localStorage.ts │ │ ├── i18n │ │ │ ├── index.ts │ │ │ ├── translations │ │ │ │ └── index.ts │ │ │ └── languageDetector.ts │ │ ├── index.ts │ │ └── ai │ │ │ ├── index.ts │ │ │ └── providers │ │ │ ├── openai.ts │ │ │ ├── deepseek.ts │ │ │ ├── ollama.ts │ │ │ ├── anthropic.ts │ │ │ ├── browser.ts │ │ │ └── gemini.ts │ ├── types │ │ ├── index.ts │ │ ├── api.types.ts │ │ ├── theme.types.ts │ │ ├── timer.types.ts │ │ ├── ai.types.ts │ │ └── language.types.ts │ └── config │ │ └── constants.ts ├── hooks │ ├── index.ts │ ├── useVersionControl.ts │ ├── useLanguage.ts │ ├── useLocalStorage.ts │ ├── useTheme.ts │ └── useApiKeyFromUrl.ts ├── main.tsx ├── types │ ├── global.d.ts │ └── index.ts ├── components │ ├── Logo.tsx │ ├── ui │ │ ├── Logo.tsx │ │ ├── IconButton.tsx │ │ └── ThemeSelector.tsx │ ├── LoadingScreen.tsx │ ├── success │ │ └── SuccessPage.tsx │ ├── layout │ │ └── Layout.tsx │ ├── Layout.tsx │ ├── ThemeSelector.tsx │ └── SessionDetails.tsx ├── stripe-config.ts ├── App.tsx └── utils │ └── openai.ts ├── .github └── FUNDING.yml ├── netlify.toml ├── postcss.config.js ├── vercel.json ├── tsconfig.json ├── .gitignore ├── tsconfig.node.json ├── tsconfig.app.json ├── vite.config.ts ├── eslint.config.js ├── LICENSE ├── package.json ├── scripts ├── deploy.js └── version-manager.js └── tailwind.config.js /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: oluiscabral 2 | patreon: studorama 3 | -------------------------------------------------------------------------------- /src/core/services/theme/index.ts: -------------------------------------------------------------------------------- 1 | export * from './themeRegistry'; -------------------------------------------------------------------------------- /src/core/services/version/index.ts: -------------------------------------------------------------------------------- 1 | export * from './versionControl'; -------------------------------------------------------------------------------- /src/core/services/notification/index.ts: -------------------------------------------------------------------------------- 1 | export * from './notificationService'; -------------------------------------------------------------------------------- /src/core/services/storage/index.ts: -------------------------------------------------------------------------------- 1 | export * as localStorage from './localStorage'; -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "/index.html" 4 | status = 200 -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /src/core/services/i18n/index.ts: -------------------------------------------------------------------------------- 1 | export * from './translations'; 2 | export * from './languageDetector'; -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/apple-touch-icon.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /public/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /public/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /public/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /public/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /public/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /public/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oluiscabral/studorama/HEAD/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | Options -MultiViews 2 | RewriteEngine On 3 | RewriteCond %{REQUEST_FILENAME} !-f 4 | RewriteRule ^ index.html [QSA,L] -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "rewrites": [ 3 | { 4 | "source": "/(.*)", 5 | "destination": "/index.html" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /public/version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.6.3", 3 | "buildDate": "2025-07-22T02:08:19.963Z", 4 | "buildNumber": 1753150099963, 5 | "environment": "development" 6 | } -------------------------------------------------------------------------------- /src/core/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from './i18n'; 2 | export * from './theme'; 3 | export * from './storage'; 4 | export * from './version'; 5 | export * from './notification'; -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './useLocalStorage'; 2 | export * from './useLanguage'; 3 | export * from './useTheme'; 4 | export * from './useApiKeyFromUrl'; 5 | export * from './useVersionControl'; -------------------------------------------------------------------------------- /src/core/types/index.ts: -------------------------------------------------------------------------------- 1 | // Re-export all types from their respective files 2 | export * from './language.types'; 3 | export * from './theme.types'; 4 | export * from './timer.types'; 5 | export * from './api.types'; -------------------------------------------------------------------------------- /src/core/services/ai/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * AI Service exports 3 | */ 4 | 5 | export * from './aiService'; 6 | export * from './providers/registry'; 7 | export * from './prompts/promptRegistry'; 8 | export * from '../../types/ai.types'; 9 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | import './index.css'; 5 | 6 | // Create root and render app 7 | createRoot(document.getElementById('root')!).render( 8 | 9 | 10 | 11 | ); -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | // Global type declarations for build-time constants 2 | 3 | // Extend Vite's ImportMetaEnv interface 4 | interface ImportMetaEnv { 5 | readonly VITE_SUPABASE_URL?: string; 6 | readonly VITE_SUPABASE_ANON_KEY?: string; 7 | } 8 | 9 | interface ImportMeta { 10 | readonly env: ImportMetaEnv; 11 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .bolt 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | .env 27 | package-lock.json -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | 2 | interface LogoProps { 3 | size?: 'sm' | 'md' | 'lg'; 4 | className?: string; 5 | } 6 | 7 | export default function Logo({ size = 'md', className = '' }: LogoProps) { 8 | const sizeClasses = { 9 | sm: 'w-8 h-8 text-2xl', 10 | md: 'w-12 h-12 text-4xl', 11 | lg: 'w-16 h-16 text-5xl' 12 | }; 13 | 14 | return ( 15 |
16 | S 17 |
18 | ); 19 | } -------------------------------------------------------------------------------- /src/components/ui/Logo.tsx: -------------------------------------------------------------------------------- 1 | 2 | interface LogoProps { 3 | size?: 'sm' | 'md' | 'lg'; 4 | className?: string; 5 | } 6 | 7 | export default function Logo({ size = 'md', className = '' }: LogoProps) { 8 | const sizeClasses = { 9 | sm: 'w-8 h-8 text-2xl', 10 | md: 'w-12 h-12 text-4xl', 11 | lg: 'w-16 h-16 text-5xl' 12 | }; 13 | 14 | return ( 15 |
16 | S 17 |
18 | ); 19 | } -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"] 24 | } 25 | -------------------------------------------------------------------------------- /src/core/types/api.types.ts: -------------------------------------------------------------------------------- 1 | export interface APISettings { 2 | openaiApiKey: string; 3 | model: string; 4 | customPrompts: { 5 | multipleChoice: string; 6 | dissertative: string; 7 | evaluation: string; 8 | elaborativePrompt: string; 9 | retrievalPrompt: string; 10 | }; 11 | preloadQuestions?: number; // Number of questions to preload 12 | } 13 | 14 | export interface PreloadingSettings { 15 | preloadQuestions: number; 16 | enableBackgroundLoading: boolean; 17 | } 18 | 19 | export interface Product { 20 | id: string; 21 | priceId: string; 22 | name: string; 23 | description: string; 24 | mode: 'payment' | 'subscription'; 25 | price: number; 26 | checkoutUrl?: string; // Optional external checkout URL 27 | } -------------------------------------------------------------------------------- /src/core/services/i18n/translations/index.ts: -------------------------------------------------------------------------------- 1 | import { Language, Translations } from '../../../types'; 2 | import { enUS } from './en-US'; 3 | import { ptBR } from './pt-BR'; 4 | 5 | // Map of all available translations 6 | const translationsMap: Record = { 7 | 'en-US': enUS, 8 | 'pt-BR': ptBR 9 | }; 10 | 11 | /** 12 | * Get translations for a specific language 13 | */ 14 | export function getTranslations(language: Language): Translations { 15 | return translationsMap[language] || translationsMap['en-US']; 16 | } 17 | 18 | /** 19 | * Get all available translations 20 | */ 21 | export function getAllTranslations(): Record { 22 | return translationsMap; 23 | } 24 | 25 | /** 26 | * Add a new language to the translations map 27 | * This allows for dynamic language additions 28 | */ 29 | export function addLanguage(language: Language, translations: Translations): void { 30 | translationsMap[language] = translations; 31 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [react()], 7 | optimizeDeps: { 8 | exclude: ['lucide-react'], 9 | include: ['react', 'react-dom', 'react-router-dom'] 10 | }, 11 | build: { 12 | rollupOptions: { 13 | output: { 14 | manualChunks: { 15 | vendor: ['react', 'react-dom'], 16 | router: ['react-router-dom'], 17 | icons: ['lucide-react'] 18 | } 19 | } 20 | }, 21 | sourcemap: false, // Disable sourcemaps in production to reduce bundle size 22 | minify: 'esbuild', // Use esbuild instead of terser for faster builds 23 | target: 'esnext' 24 | }, 25 | server: { 26 | fs: { 27 | strict: false 28 | } 29 | }, 30 | preview: { 31 | // Configure preview server to handle SPA routing 32 | host: true, 33 | port: 4173 34 | } 35 | }); -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | import reactHooks from 'eslint-plugin-react-hooks'; 4 | import reactRefresh from 'eslint-plugin-react-refresh'; 5 | import tseslint from 'typescript-eslint'; 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | '@typescript-eslint/ban-types': 'warn', 27 | '@typescript-eslint/no-unused-vars': 'off', 28 | '@typescript-eslint/ban-ts-comment': 'off', 29 | '@typescript-eslint/no-explicit-any': 'off', 30 | 'react-refresh/only-export-components': 'off', 31 | }, 32 | } 33 | ); 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 oluiscabral 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the Software), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | # Sitemap 5 | Sitemap: https://www.studorama.com/sitemap.xml 6 | 7 | # Crawl-delay for respectful crawling 8 | Crawl-delay: 1 9 | 10 | # Block access to sensitive areas (none for this app) 11 | # Disallow: /admin/ 12 | # Disallow: /private/ 13 | 14 | # Allow all major search engines 15 | User-agent: Googlebot 16 | Allow: / 17 | 18 | User-agent: Bingbot 19 | Allow: / 20 | 21 | User-agent: Slurp 22 | Allow: / 23 | 24 | User-agent: DuckDuckBot 25 | Allow: / 26 | 27 | User-agent: Baiduspider 28 | Allow: / 29 | 30 | User-agent: YandexBot 31 | Allow: / 32 | 33 | User-agent: facebookexternalhit 34 | Allow: / 35 | 36 | User-agent: Twitterbot 37 | Allow: / 38 | 39 | User-agent: LinkedInBot 40 | Allow: / 41 | 42 | # Portuguese content 43 | User-agent: * 44 | Allow: /?lang=pt-BR 45 | 46 | # Host directive 47 | Host: https://www.studorama.com 48 | 49 | # Additional crawling instructions 50 | User-agent: * 51 | Crawl-delay: 1 52 | Request-rate: 1/1s 53 | 54 | # Allow crawling of all educational content 55 | Allow: /study 56 | Allow: /history 57 | Allow: /settings 58 | Allow: /pricing 59 | 60 | # Encourage indexing of multilingual content 61 | Allow: /*?lang=* -------------------------------------------------------------------------------- /src/core/types/theme.types.ts: -------------------------------------------------------------------------------- 1 | export type Theme = 2 | | 'light' 3 | | 'dark' 4 | | 'focus' 5 | | 'midnight' 6 | | 'forest' 7 | | 'ocean' 8 | | 'sunset' 9 | | 'neon' 10 | | 'minimal' 11 | | 'warm'; 12 | 13 | export type ThemeCategory = 'standard' | 'focus' | 'energy' | 'calm'; 14 | 15 | export interface ThemeConfig { 16 | id: Theme; 17 | name: string; 18 | description: string; 19 | category: ThemeCategory; 20 | colors: { 21 | primary: string; 22 | primaryHover: string; 23 | secondary: string; 24 | accent: string; 25 | background: string; 26 | surface: string; 27 | surfaceHover: string; 28 | text: string; 29 | textSecondary: string; 30 | textMuted: string; 31 | border: string; 32 | borderHover: string; 33 | success: string; 34 | warning: string; 35 | error: string; 36 | info: string; 37 | }; 38 | gradients: { 39 | primary: string; 40 | secondary: string; 41 | background: string; 42 | card: string; 43 | }; 44 | shadows: { 45 | sm: string; 46 | md: string; 47 | lg: string; 48 | xl: string; 49 | }; 50 | effects: { 51 | blur: string; 52 | glow: string; 53 | animation: string; 54 | }; 55 | } -------------------------------------------------------------------------------- /src/core/types/timer.types.ts: -------------------------------------------------------------------------------- 1 | export interface TimerSettings { 2 | sessionTimerEnabled: boolean; 3 | sessionTimerDuration?: number; // in minutes 4 | questionTimerEnabled: boolean; 5 | questionTimerDuration?: number; // in seconds 6 | accumulateQuestionTime: boolean; 7 | showTimerWarnings: boolean; 8 | autoSubmitOnTimeout: boolean; 9 | soundEnabled?: boolean; 10 | vibrationEnabled?: boolean; 11 | } 12 | 13 | export interface SessionTimer { 14 | startTime: string; 15 | endTime?: string; 16 | pausedTime?: number; // accumulated paused time in ms 17 | isPaused: boolean; 18 | totalElapsed?: number; // in ms 19 | } 20 | 21 | export interface QuestionTimer { 22 | questionId: string; 23 | startTime: string; 24 | endTime?: string; 25 | pausedTime?: number; 26 | timeSpent: number; // in ms 27 | accumulatedTime?: number; // from previous questions if accumulating 28 | timedOut: boolean; 29 | } 30 | 31 | // Timer preferences stored globally with auto-save 32 | export interface TimerPreferences { 33 | rememberChoice: boolean; 34 | defaultSessionTimerEnabled: boolean; 35 | defaultSessionTimer: number; // in minutes 36 | defaultQuestionTimerEnabled: boolean; 37 | defaultQuestionTimer: number; // in seconds 38 | defaultAccumulateTime: boolean; 39 | defaultShowWarnings: boolean; 40 | defaultAutoSubmit: boolean; 41 | soundEnabled: boolean; 42 | vibrationEnabled: boolean; 43 | } -------------------------------------------------------------------------------- /src/components/LoadingScreen.tsx: -------------------------------------------------------------------------------- 1 | import { useLanguage } from '../hooks'; 2 | import Logo from './ui/Logo'; 3 | 4 | export default function LoadingScreen() { 5 | const { language } = useLanguage(); 6 | 7 | return ( 8 |
9 |
10 | {/* Animated Logo */} 11 |
12 | 13 |
14 | 15 | {/* Loading Text */} 16 |

17 | Studorama 18 |

19 |

20 | {language === 'pt-BR' 21 | ? 'Carregando sua experiência de aprendizado...' 22 | : 'Loading your learning experience...'} 23 |

24 | 25 | {/* Loading Spinner */} 26 |
27 |
28 |
29 | 30 | {/* Performance Tip */} 31 |
32 | {language === 'pt-BR' 33 | ? 'Otimizando dados para melhor performance...' 34 | : 'Optimizing data for better performance...'} 35 |
36 |
37 |
38 | ); 39 | } -------------------------------------------------------------------------------- /src/core/config/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Application-wide constants 3 | */ 4 | 5 | // Current application version - static string updated by version manager 6 | export const APP_VERSION = '2.6.3'; 7 | 8 | // Local storage keys 9 | export const STORAGE_KEYS = { 10 | API_SETTINGS: 'studorama-api-settings', 11 | LANGUAGE: 'studorama-language', 12 | SESSIONS: 'studorama-sessions', 13 | THEME: 'studorama-theme', 14 | VERSION: 'studorama-app-version' 15 | }; 16 | 17 | // Default values 18 | export const DEFAULTS = { 19 | LANGUAGE: 'en-US', 20 | THEME: 'light', 21 | PRELOAD_QUESTIONS: 3, 22 | MODEL: 'gpt-4o-mini', 23 | SESSION_TIMER: 30, // minutes 24 | QUESTION_TIMER: 60 // seconds 25 | }; 26 | 27 | // API related constants 28 | export const API = { 29 | OPENAI_API_URL: 'https://api.openai.com/v1/chat/completions', 30 | OPENAI_KEY_PREFIX: 'sk-' 31 | }; 32 | 33 | // Supported languages 34 | export const SUPPORTED_LANGUAGES = ['en-US', 'pt-BR']; 35 | 36 | // Supported themes 37 | export const THEME_CATEGORIES = ['standard', 'focus', 'energy', 'calm'] as const; 38 | 39 | // Cache names for service worker (updated automatically by version manager) 40 | export const CACHE_NAMES = { 41 | STATIC: `studorama-static-v2.6.3`, 42 | DYNAMIC: `studorama-dynamic-v2.6.3`, 43 | MAIN: `studorama-v2.6.3` 44 | }; 45 | 46 | // Static files to cache 47 | export const STATIC_FILES = [ 48 | '/', 49 | '/index.html', 50 | '/manifest.json', 51 | '/favicon.ico', 52 | '/apple-touch-icon.png' 53 | ]; 54 | 55 | // Keys to preserve during version migration 56 | export const PRESERVED_STORAGE_KEYS = [ 57 | STORAGE_KEYS.API_SETTINGS, 58 | STORAGE_KEYS.LANGUAGE, 59 | STORAGE_KEYS.SESSIONS, 60 | STORAGE_KEYS.THEME, 61 | ]; 62 | -------------------------------------------------------------------------------- /src/hooks/useVersionControl.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { 3 | handleVersionMigration, 4 | getVersionInfo 5 | } from '../core/services/version/versionControl'; 6 | import { APP_VERSION } from '../core/config/constants'; 7 | 8 | /** 9 | * Hook to manage version control and migration with performance optimizations 10 | */ 11 | export function useVersionControl() { 12 | const [migrationPerformed, setMigrationPerformed] = useState(false); 13 | const [isReady, setIsReady] = useState(false); 14 | 15 | useEffect(() => { 16 | // Perform version check and migration on app startup 17 | const performMigration = async () => { 18 | try { 19 | // Use requestIdleCallback to avoid blocking the main thread 20 | const runMigration = () => { 21 | return new Promise((resolve) => { 22 | requestIdleCallback(() => { 23 | try { 24 | const migrated = handleVersionMigration(); 25 | resolve(migrated); 26 | } catch (error) { 27 | console.error('Error during version migration:', error); 28 | resolve(false); 29 | } 30 | }); 31 | }); 32 | }; 33 | 34 | const migrated = await runMigration(); 35 | setMigrationPerformed(migrated); 36 | 37 | if (migrated) { 38 | console.log('Version migration completed successfully'); 39 | } else { 40 | console.log('No migration needed - version is current'); 41 | } 42 | } catch (error) { 43 | console.error('Error during version migration:', error); 44 | } finally { 45 | setIsReady(true); 46 | } 47 | }; 48 | 49 | // Run migration check 50 | performMigration(); 51 | }, []); 52 | 53 | return { 54 | currentVersion: APP_VERSION, 55 | migrationPerformed, 56 | isReady, 57 | versionInfo: getVersionInfo(), 58 | }; 59 | } -------------------------------------------------------------------------------- /src/stripe-config.ts: -------------------------------------------------------------------------------- 1 | export interface Product { 2 | id: string; 3 | priceId: string; 4 | name: string; 5 | description: string; 6 | mode: 'payment' | 'subscription'; 7 | price: number; 8 | checkoutUrl?: string; // Optional external checkout URL 9 | } 10 | 11 | export const products: Product[] = [ 12 | { 13 | id: 'prod_SbO1ff2wVlI1Me', 14 | priceId: 'price_1RgBFtGzYU9LC2rhWCNUvQNG', 15 | name: 'Advanced', 16 | description: 'Support Studorama development with a generous monthly contribution. Help us maintain and improve the platform for everyone.', 17 | mode: 'subscription', 18 | price: 50.00, 19 | checkoutUrl: 'https://buy.stripe.com/test_advanced_monthly' // Replace with actual Stripe checkout URL 20 | }, 21 | { 22 | id: 'prod_SbO1n8r99BIcBa', 23 | priceId: 'price_1RgBFfGzYU9LC2rh4LDpFBCr', 24 | name: 'Standard', 25 | description: 'Show your appreciation with a monthly contribution. Every bit helps us keep Studorama free and accessible.', 26 | mode: 'subscription', 27 | price: 15.00, 28 | checkoutUrl: 'https://buy.stripe.com/test_standard_monthly' // Replace with actual Stripe checkout URL 29 | }, 30 | { 31 | id: 'prod_SbO0pzgHDGvxyA', 32 | priceId: 'price_1RgBEPGzYU9LC2rhy3WTMOmk', 33 | name: 'Basic', 34 | description: 'Buy us a coffee each month! A small gesture that makes a big difference in supporting our mission.', 35 | mode: 'subscription', 36 | price: 5.00, 37 | checkoutUrl: 'https://buy.stripe.com/test_basic_monthly' // Replace with actual Stripe checkout URL 38 | } 39 | ]; 40 | 41 | export function getProductByPriceId(priceId: string): Product | undefined { 42 | return products.find(product => product.priceId === priceId); 43 | } 44 | 45 | export function getProductById(id: string): Product | undefined { 46 | return products.find(product => product.id === id); 47 | } 48 | 49 | // Helper function to get checkout URL for a product 50 | export function getCheckoutUrl(priceId: string): string | null { 51 | const product = getProductByPriceId(priceId); 52 | return product?.checkoutUrl || null; 53 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "studorama", 3 | "private": false, 4 | "version": "2.6.3", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview", 11 | "version:bump": "node scripts/version-manager.js bump", 12 | "version:major": "node scripts/version-manager.js major", 13 | "version:minor": "node scripts/version-manager.js minor", 14 | "version:patch": "node scripts/version-manager.js patch", 15 | "version:info": "node scripts/version-manager.js info", 16 | "deploy": "node scripts/deploy.js deploy", 17 | "deploy:major": "node scripts/deploy.js deploy major", 18 | "deploy:minor": "node scripts/deploy.js deploy minor", 19 | "deploy:patch": "node scripts/deploy.js deploy patch", 20 | "predeploy": "npm run lint", 21 | "postdeploy": "echo 'Deployment completed successfully!'" 22 | }, 23 | "dependencies": { 24 | "highlight.js": "^11.11.1", 25 | "json5": "^2.2.3", 26 | "lodash": "^4.17.21", 27 | "lucide-react": "^0.344.0", 28 | "react": "^18.3.1", 29 | "react-dom": "^18.3.1", 30 | "react-markdown": "^9.1.0", 31 | "react-router-dom": "^6.30.1", 32 | "react-syntax-highlighter": "^15.6.1", 33 | "rehype-highlight": "^7.0.2", 34 | "rehype-katex": "^7.0.1", 35 | "remark-breaks": "^4.0.0", 36 | "remark-gfm": "^4.0.1", 37 | "remark-math": "^6.0.0" 38 | }, 39 | "devDependencies": { 40 | "@eslint/js": "^9.31.0", 41 | "@types/dom-helpers": "^3.4.1", 42 | "@types/lodash": "^4.17.20", 43 | "@types/react": "^18.3.23", 44 | "@types/react-dom": "^18.3.7", 45 | "@types/react-syntax-highlighter": "^15.5.13", 46 | "@vitejs/plugin-react": "^4.7.0", 47 | "autoprefixer": "^10.4.21", 48 | "eslint": "^9.31.0", 49 | "eslint-plugin-react-hooks": "^5.2.0", 50 | "eslint-plugin-react-refresh": "^0.4.20", 51 | "globals": "^15.15.0", 52 | "postcss": "^8.5.6", 53 | "tailwindcss": "^3.4.17", 54 | "terser": "^5.43.1", 55 | "typescript": "^5.8.3", 56 | "typescript-eslint": "^8.38.0", 57 | "vite": "^5.4.19" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { Route, BrowserRouter as Router, Routes } from 'react-router-dom'; 2 | import Layout from './components/layout/Layout'; 3 | import Dashboard from './components/pages/Dashboard'; 4 | import SessionHistory from './components/pages/SessionHistory'; 5 | import PricingPage from './components/pricing/PricingPage'; 6 | import SessionDetails from './components/SessionDetails'; 7 | import SettingsPage from './components/pages/SettingsPage/SettingsPage'; 8 | import StudyPage from './components/pages/StudyPage/StudyPage'; 9 | import SuccessPage from './components/success/SuccessPage'; 10 | import { processApiKeyFromUrl, useApiKeyFromUrl, useVersionControl } from './hooks'; 11 | import { cleanupCorruptedEntries } from './core/services/storage/localStorage'; 12 | import LoadingScreen from './components/LoadingScreen'; 13 | 14 | // Clean up any corrupted localStorage entries first 15 | cleanupCorruptedEntries(); 16 | 17 | // Process API key from URL immediately before any React rendering 18 | // This ensures the API key is preserved during version migrations 19 | processApiKeyFromUrl(); 20 | 21 | function App() { 22 | // Initialize version control first 23 | const { isReady } = useVersionControl(); 24 | 25 | // Initialize API key from URL if present (after version control) 26 | useApiKeyFromUrl(); 27 | 28 | // Show loading screen while version control is initializing 29 | if (!isReady) { 30 | return ; 31 | } 32 | 33 | return ( 34 | 35 | 36 | 37 | } /> 38 | } /> 39 | } /> 40 | } /> 41 | } /> 42 | } /> 43 | } /> 44 | {/* Catch all route - redirect to home */} 45 | } /> 46 | 47 | 48 | 49 | ); 50 | } 51 | 52 | export default App; 53 | -------------------------------------------------------------------------------- /src/components/success/SuccessPage.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRight, CheckCircle, Home } from 'lucide-react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | export default function SuccessPage() { 5 | return ( 6 |
7 |
8 |
9 |
10 | 11 |
12 | 13 |

14 | Thank You for Your Support! 15 |

16 | 17 |

18 | Your sponsorship helps keep Studorama free and accessible for everyone. We truly appreciate your contribution to the mission of making quality education available to all. 19 |

20 | 21 |
22 | 26 | 27 | Continue Learning 28 | 29 | 30 | 34 | Back to Sponsorship 35 | 36 | 37 |
38 |
39 | 40 |
41 |

42 | Questions? Contact us at{' '} 43 | 44 | support@studorama.com 45 | 46 |

47 |
48 |
49 |
50 | ); 51 | } -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Studorama - AI-Powered Study Sessions", 3 | "short_name": "Studorama", 4 | "description": "Free AI-powered study sessions with proven learning techniques. No account required.", 5 | "start_url": "/", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#ea580c", 9 | "orientation": "portrait-primary", 10 | "scope": "/", 11 | "lang": "en", 12 | "categories": ["education", "productivity", "utilities"], 13 | "icons": [ 14 | { 15 | "src": "/favicon-16x16.png", 16 | "sizes": "16x16", 17 | "type": "image/png" 18 | }, 19 | { 20 | "src": "/favicon-32x32.png", 21 | "sizes": "32x32", 22 | "type": "image/png" 23 | }, 24 | { 25 | "src": "/android-chrome-192x192.png", 26 | "sizes": "192x192", 27 | "type": "image/png", 28 | "purpose": "any maskable" 29 | }, 30 | { 31 | "src": "/android-chrome-512x512.png", 32 | "sizes": "512x512", 33 | "type": "image/png", 34 | "purpose": "any maskable" 35 | }, 36 | { 37 | "src": "/apple-touch-icon.png", 38 | "sizes": "180x180", 39 | "type": "image/png" 40 | } 41 | ], 42 | "screenshots": [ 43 | { 44 | "src": "/screenshot-mobile.png", 45 | "sizes": "390x844", 46 | "type": "image/png", 47 | "form_factor": "narrow", 48 | "label": "Studorama on mobile" 49 | }, 50 | { 51 | "src": "/screenshot-desktop.png", 52 | "sizes": "1280x720", 53 | "type": "image/png", 54 | "form_factor": "wide", 55 | "label": "Studorama on desktop" 56 | } 57 | ], 58 | "shortcuts": [ 59 | { 60 | "name": "Start Study Session", 61 | "short_name": "Study", 62 | "description": "Start a new AI-powered study session", 63 | "url": "/study", 64 | "icons": [ 65 | { 66 | "src": "/favicon-32x32.png", 67 | "sizes": "32x32" 68 | } 69 | ] 70 | }, 71 | { 72 | "name": "View History", 73 | "short_name": "History", 74 | "description": "View your study session history", 75 | "url": "/history", 76 | "icons": [ 77 | { 78 | "src": "/favicon-32x32.png", 79 | "sizes": "32x32" 80 | } 81 | ] 82 | } 83 | ], 84 | "related_applications": [], 85 | "prefer_related_applications": false, 86 | "edge_side_panel": { 87 | "preferred_width": 400 88 | } 89 | } -------------------------------------------------------------------------------- /src/hooks/useLanguage.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | import { useLocalStorage } from './useLocalStorage'; 3 | import { Language } from '../core/types'; 4 | import { detectBrowserLanguage, getTranslations } from '../core/services/i18n'; 5 | import { STORAGE_KEYS } from '../core/config/constants'; 6 | 7 | /** 8 | * Hook for managing application language and translations 9 | */ 10 | export function useLanguage() { 11 | // Load language settings from localStorage 12 | const [languageSettings, setLanguageSettings] = useLocalStorage<{ language: Language }>( 13 | STORAGE_KEYS.LANGUAGE, 14 | { language: detectBrowserLanguage() } 15 | ); 16 | 17 | // Local state for current language 18 | const [currentLanguage, setCurrentLanguage] = useState(languageSettings.language); 19 | 20 | // Update local state when localStorage changes 21 | useEffect(() => { 22 | setCurrentLanguage(languageSettings.language); 23 | }, [languageSettings.language]); 24 | 25 | /** 26 | * Change the application language 27 | */ 28 | const changeLanguage = useCallback((newLanguage: Language) => { 29 | // Update the language setting 30 | setLanguageSettings({ language: newLanguage }); 31 | setCurrentLanguage(newLanguage); 32 | 33 | // Use a more reliable refresh method that always goes to home page 34 | setTimeout(() => { 35 | try { 36 | // Always redirect to home page to avoid 404 errors 37 | const baseUrl = window.location.origin; 38 | const homeUrl = `${baseUrl}/`; 39 | 40 | // Use window.location.href for the most reliable redirect 41 | window.location.href = homeUrl; 42 | } catch (error) { 43 | console.error('Error during language change redirect:', error); 44 | 45 | // Fallback: try to reload the current page 46 | try { 47 | window.location.reload(); 48 | } catch (fallbackError) { 49 | console.error('Fallback reload also failed:', fallbackError); 50 | 51 | // Last resort: force navigation to home 52 | try { 53 | window.location.replace('/'); 54 | } catch (lastResortError) { 55 | console.error('All redirect methods failed:', lastResortError); 56 | } 57 | } 58 | } 59 | }, 200); // Longer delay to ensure localStorage is fully updated 60 | }, [setLanguageSettings]); 61 | 62 | // Get translations for current language 63 | const t = getTranslations(currentLanguage); 64 | 65 | return { 66 | language: currentLanguage, 67 | changeLanguage, 68 | t 69 | }; 70 | } -------------------------------------------------------------------------------- /src/core/services/ai/providers/openai.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * OpenAI Provider Implementation 3 | */ 4 | 5 | import { AIRequest, AIResponse, AIError } from '../../../types/ai.types'; 6 | 7 | export class OpenAIProvider { 8 | private baseUrl = 'https://api.openai.com/v1'; 9 | 10 | async generateCompletion(request: AIRequest): Promise { 11 | if (!request.apiKey) { 12 | throw this.createError('API key is required for OpenAI', 'MISSING_API_KEY'); 13 | } 14 | 15 | try { 16 | const response = await fetch(`${this.baseUrl}/chat/completions`, { 17 | method: 'POST', 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | 'Authorization': `Bearer ${request.apiKey}`, 21 | ...request.customHeaders, 22 | }, 23 | body: JSON.stringify({ 24 | model: request.model, 25 | messages: request.messages, 26 | temperature: request.temperature || 0.7, 27 | max_tokens: request.maxTokens || 800, 28 | presence_penalty: 0.1, 29 | frequency_penalty: 0.1, 30 | }), 31 | }); 32 | 33 | if (!response.ok) { 34 | const errorData = await response.json().catch(() => ({})); 35 | throw this.createError( 36 | `OpenAI API error: ${response.statusText}. ${errorData.error?.message || ''}`, 37 | errorData.error?.code || 'API_ERROR', 38 | response.status 39 | ); 40 | } 41 | 42 | const data = await response.json(); 43 | 44 | if (!data.choices || !data.choices[0] || !data.choices[0].message) { 45 | throw this.createError('Invalid response format from OpenAI', 'INVALID_RESPONSE'); 46 | } 47 | 48 | return { 49 | content: data.choices[0].message.content, 50 | usage: data.usage ? { 51 | promptTokens: data.usage.prompt_tokens, 52 | completionTokens: data.usage.completion_tokens, 53 | totalTokens: data.usage.total_tokens, 54 | } : undefined, 55 | model: data.model, 56 | finishReason: data.choices[0].finish_reason, 57 | }; 58 | } catch (error) { 59 | if (error instanceof Error && error.name === 'AIError') { 60 | throw error; 61 | } 62 | 63 | throw this.createError( 64 | `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`, 65 | 'NETWORK_ERROR', 66 | undefined, 67 | true 68 | ); 69 | } 70 | } 71 | 72 | private createError( 73 | message: string, 74 | code?: string, 75 | statusCode?: number, 76 | retryable = false 77 | ): AIError { 78 | const error = new Error(message) as AIError; 79 | error.name = 'AIError'; 80 | error.provider = 'openai'; 81 | error.code = code; 82 | error.statusCode = statusCode; 83 | error.retryable = retryable; 84 | return error; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/core/services/ai/providers/deepseek.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * DeepSeek Provider Implementation 3 | */ 4 | 5 | import { AIRequest, AIResponse, AIError } from '../../../types/ai.types'; 6 | 7 | export class DeepSeekProvider { 8 | private baseUrl = 'https://api.deepseek.com/v1'; 9 | 10 | async generateCompletion(request: AIRequest): Promise { 11 | if (!request.apiKey) { 12 | throw this.createError('API key is required for DeepSeek', 'MISSING_API_KEY'); 13 | } 14 | 15 | try { 16 | const response = await fetch(`${request.baseUrl || this.baseUrl}/chat/completions`, { 17 | method: 'POST', 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | 'Authorization': `Bearer ${request.apiKey}`, 21 | ...request.customHeaders, 22 | }, 23 | body: JSON.stringify({ 24 | model: request.model, 25 | messages: request.messages, 26 | temperature: request.temperature || 0.7, 27 | max_tokens: request.maxTokens || 800, 28 | top_p: 0.9, 29 | frequency_penalty: 0.1, 30 | presence_penalty: 0.1, 31 | }), 32 | }); 33 | 34 | if (!response.ok) { 35 | const errorData = await response.json().catch(() => ({})); 36 | throw this.createError( 37 | `DeepSeek API error: ${response.statusText}. ${errorData.error?.message || ''}`, 38 | errorData.error?.code || 'API_ERROR', 39 | response.status 40 | ); 41 | } 42 | 43 | const data = await response.json(); 44 | 45 | if (!data.choices || !data.choices[0] || !data.choices[0].message) { 46 | throw this.createError('Invalid response format from DeepSeek', 'INVALID_RESPONSE'); 47 | } 48 | 49 | return { 50 | content: data.choices[0].message.content, 51 | usage: data.usage ? { 52 | promptTokens: data.usage.prompt_tokens, 53 | completionTokens: data.usage.completion_tokens, 54 | totalTokens: data.usage.total_tokens, 55 | } : undefined, 56 | model: data.model, 57 | finishReason: data.choices[0].finish_reason, 58 | }; 59 | } catch (error) { 60 | if (error instanceof Error && error.name === 'AIError') { 61 | throw error; 62 | } 63 | 64 | throw this.createError( 65 | `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`, 66 | 'NETWORK_ERROR', 67 | undefined, 68 | true 69 | ); 70 | } 71 | } 72 | 73 | private createError( 74 | message: string, 75 | code?: string, 76 | statusCode?: number, 77 | retryable = false 78 | ): AIError { 79 | const error = new Error(message) as AIError; 80 | error.name = 'AIError'; 81 | error.provider = 'deepseek'; 82 | error.code = code; 83 | error.statusCode = statusCode; 84 | error.retryable = retryable; 85 | return error; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback, useMemo, useRef } from "react"; 2 | import { localStorage } from "../core/services"; 3 | 4 | /** 5 | * Custom hook for using localStorage with React state 6 | * Optimized for performance with large datasets 7 | */ 8 | export function useLocalStorage(key: string, initialValue: T) { 9 | // Use ref to track if we're initializing to prevent unnecessary re-renders 10 | const isInitializing = useRef(true); 11 | const lastValueRef = useRef(); 12 | 13 | // Get stored value or initialize with provided default 14 | const [storedValue, setStoredValue] = useState(() => { 15 | try { 16 | const item = localStorage.getItem(key, initialValue); 17 | lastValueRef.current = item; 18 | return item; 19 | } catch (error) { 20 | console.error(`Error reading localStorage key "${key}":`, error); 21 | return initialValue; 22 | } finally { 23 | isInitializing.current = false; 24 | } 25 | }); 26 | 27 | // Function to update both state and localStorage 28 | const setValue = useCallback( 29 | (value: T | ((val: T) => T)) => { 30 | try { 31 | // Allow value to be a function for previous state pattern 32 | const valueToStore = value instanceof Function ? value(lastValueRef.current || storedValue) : value; 33 | 34 | // Only update if value actually changed (deep comparison for objects) 35 | if (JSON.stringify(lastValueRef.current) === JSON.stringify(valueToStore)) { 36 | return; 37 | } 38 | 39 | // Update refs first 40 | lastValueRef.current = valueToStore; 41 | 42 | // Update state 43 | setStoredValue(valueToStore); 44 | 45 | // Save to localStorage asynchronously to avoid blocking 46 | requestIdleCallback(() => { 47 | try { 48 | localStorage.setItem(key, valueToStore); 49 | } catch (error) { 50 | console.error(`Error setting localStorage key "${key}":`, error); 51 | } 52 | }); 53 | } catch (error) { 54 | console.error(`Error processing localStorage update for key "${key}":`, error); 55 | } 56 | }, 57 | [key, storedValue] 58 | ); 59 | 60 | // Sync with localStorage changes from other tabs/windows 61 | useEffect(() => { 62 | function handleStorageChange(e: StorageEvent) { 63 | if (e.key === key && e.newValue !== null) { 64 | try { 65 | const newValue = JSON.parse(e.newValue); 66 | // Only update if different from current value 67 | if (JSON.stringify(lastValueRef.current) !== JSON.stringify(newValue)) { 68 | lastValueRef.current = newValue; 69 | setStoredValue(newValue); 70 | } 71 | } catch (error) { 72 | console.error(`Error parsing localStorage change for key "${key}":`, error); 73 | } 74 | } 75 | } 76 | 77 | // Only add listener after initial load to avoid unnecessary work 78 | if (!isInitializing.current) { 79 | window.addEventListener("storage", handleStorageChange); 80 | return () => { 81 | window.removeEventListener("storage", handleStorageChange); 82 | }; 83 | } 84 | }, [key]); 85 | 86 | return useMemo(() => [storedValue, setValue] as const, [storedValue, setValue]); 87 | } -------------------------------------------------------------------------------- /src/utils/openai.ts: -------------------------------------------------------------------------------- 1 | import JSON5 from 'json5'; 2 | 3 | /** 4 | * Robustly extract JSON or JSON5 content from arbitrary strings 5 | */ 6 | export function extractJSON(raw: string): string { 7 | let text = raw; 8 | 9 | // Remove BOM 10 | text = text.replace(/^\uFEFF/, ''); 11 | 12 | // If there's a JSON code fence, extract that content first 13 | const fenceMatch = text.match(/```json\s*([\s\S]*?)```/i); 14 | if (fenceMatch) { 15 | text = fenceMatch[1]; 16 | } 17 | // Otherwise, remove other fences and markers but keep content 18 | else { 19 | // Strip generic code fences (``` … ```) 20 | text = text.replace(/```[\s\S]*?```/g, match => { 21 | // preserve inner content if it looks like JSON 22 | const inner = match.replace(/```/g, ''); 23 | if (/^[\s]*[{\[]/.test(inner)) { 24 | return inner; 25 | } 26 | return ''; 27 | }); 28 | } 29 | 30 | // Remove any leading/trailing non-json text before first brace 31 | const first = text.search(/[\[{]/); 32 | const last = text.lastIndexOf('}'); 33 | if (first !== -1 && last !== -1 && last > first) { 34 | text = text.substring(first, last + 1); 35 | } 36 | 37 | // Only remove specific control characters 38 | text = text.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/g, '') 39 | .replace(/`/g, ''); 40 | 41 | // Remove trailing commas 42 | text = text.replace(/,\s*([}\]])/g, '$1'); 43 | 44 | return text.trim(); 45 | } 46 | 47 | export function parseJSON(raw: string): any { 48 | const jsonString = extractJSON(raw); 49 | 50 | try { 51 | return JSON.parse(jsonString); 52 | } catch (e) { 53 | try { 54 | return JSON5.parse(jsonString); 55 | } catch (e2) { 56 | // Try to fix common truncation issues 57 | let fixedJson = jsonString; 58 | 59 | try { 60 | // If the JSON ends with an incomplete string, try to close it 61 | if (fixedJson.match(/[^"\\]"[^"]*$/)) { 62 | fixedJson = fixedJson.replace(/([^"\\]"[^"]*)$/, '$1"'); 63 | } 64 | 65 | // If there's an incomplete array, try to close it 66 | if (fixedJson.match(/\[[^\]]*$/)) { 67 | fixedJson = fixedJson + ']'; 68 | } 69 | 70 | // If there's an incomplete object, try to close it 71 | const openBraces = (fixedJson.match(/\{/g) || []).length; 72 | const closeBraces = (fixedJson.match(/\}/g) || []).length; 73 | const missingBraces = openBraces - closeBraces; 74 | 75 | if (missingBraces > 0) { 76 | fixedJson = fixedJson + '}'.repeat(missingBraces); 77 | } 78 | 79 | // Try parsing the fixed JSON 80 | return JSON.parse(fixedJson); 81 | } catch (e3) { 82 | try { 83 | return JSON5.parse(fixedJson); 84 | } catch (e4) { 85 | console.error('Failed to parse JSON string:', jsonString); 86 | // @ts-ignore 87 | console.error('Original error:', e.message); 88 | // @ts-ignore 89 | console.error('JSON5 error:', e2.message); 90 | // @ts-ignore 91 | console.error('Recovery attempt error:', e4.message); 92 | throw new Error(`Parsing error: Response appears to be truncated. Please try again with a shorter question or check your OpenAI API limits.`); 93 | } 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/core/services/i18n/languageDetector.ts: -------------------------------------------------------------------------------- 1 | import { Language } from '../../types'; 2 | import { SUPPORTED_LANGUAGES, DEFAULTS } from '../../config/constants'; 3 | 4 | /** 5 | * Detect the browser's language and return the closest supported language 6 | */ 7 | export function detectBrowserLanguage(): Language { 8 | // Default language as fallback 9 | const defaultLanguage = DEFAULTS.LANGUAGE as Language; 10 | 11 | try { 12 | // Get browser language 13 | const browserLang = navigator.language || (navigator.languages && navigator.languages[0]); 14 | 15 | if (!browserLang) { 16 | return defaultLanguage; 17 | } 18 | 19 | // Check if the browser language is directly supported 20 | if (SUPPORTED_LANGUAGES.includes(browserLang as Language)) { 21 | return browserLang as Language; 22 | } 23 | 24 | // Check for language prefix match (e.g., 'pt-PT' should match 'pt-BR') 25 | const prefix = browserLang.split('-')[0]; 26 | 27 | for (const supportedLang of SUPPORTED_LANGUAGES) { 28 | if (supportedLang.startsWith(prefix)) { 29 | return supportedLang as Language; 30 | } 31 | } 32 | 33 | // No match found, return default 34 | return defaultLanguage; 35 | } catch (error) { 36 | console.error('Error detecting browser language:', error); 37 | return defaultLanguage; 38 | } 39 | } 40 | 41 | /** 42 | * Format a date according to the current language 43 | */ 44 | export function formatDate(date: string, language: Language): string { 45 | try { 46 | const dateObj = new Date(date); 47 | 48 | const options: Intl.DateTimeFormatOptions = { 49 | year: 'numeric', 50 | month: 'short', 51 | day: 'numeric', 52 | hour: '2-digit', 53 | minute: '2-digit' 54 | }; 55 | 56 | return dateObj.toLocaleDateString(language, options); 57 | } catch (error) { 58 | console.error('Error formatting date:', error); 59 | return date; 60 | } 61 | } 62 | 63 | /** 64 | * Get a random placeholder for subject modifiers based on language 65 | */ 66 | export function getRandomModifierPlaceholder(language: Language): string { 67 | // Random modifier placeholders by language 68 | const MODIFIER_PLACEHOLDERS: Record = { 69 | 'en-US': [ 70 | 'Introduction to Computer Science by David J. Malan', 71 | 'Chapter 3: Data Structures', 72 | 'MIT OpenCourseWare 6.006', 73 | 'Algorithms by Robert Sedgewick', 74 | 'Section 2.1: Elementary Sorts', 75 | 'Khan Academy: Linear Algebra', 76 | 'Calculus: Early Transcendentals by James Stewart', 77 | 'Physics for Scientists and Engineers by Serway', 78 | 'Organic Chemistry by Paula Bruice', 79 | 'Microeconomics by Paul Krugman' 80 | ], 81 | 'pt-BR': [ 82 | 'Introdução à Ciência da Computação por David J. Malan', 83 | 'Capítulo 3: Estruturas de Dados', 84 | 'MIT OpenCourseWare 6.006', 85 | 'Algoritmos por Robert Sedgewick', 86 | 'Seção 2.1: Ordenações Elementares', 87 | 'Khan Academy: Álgebra Linear', 88 | 'Cálculo: Transcendentais Iniciais por James Stewart', 89 | 'Física para Cientistas e Engenheiros por Serway', 90 | 'Química Orgânica por Paula Bruice', 91 | 'Microeconomia por Paul Krugman' 92 | ] 93 | }; 94 | 95 | const placeholders = MODIFIER_PLACEHOLDERS[language] || MODIFIER_PLACEHOLDERS['en-US']; 96 | return placeholders[Math.floor(Math.random() * placeholders.length)]; 97 | } -------------------------------------------------------------------------------- /src/core/services/ai/providers/ollama.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Ollama Provider Implementation 3 | */ 4 | 5 | import { AIRequest, AIResponse, AIError } from '../../../types/ai.types'; 6 | 7 | export class OllamaProvider { 8 | private baseUrl = 'http://localhost:11434/v1'; 9 | 10 | async generateCompletion(request: AIRequest): Promise { 11 | try { 12 | const response = await fetch(`${request.baseUrl || this.baseUrl}/chat/completions`, { 13 | method: 'POST', 14 | headers: { 15 | 'Content-Type': 'application/json', 16 | ...request.customHeaders, 17 | }, 18 | body: JSON.stringify({ 19 | model: request.model, 20 | messages: request.messages, 21 | temperature: request.temperature || 0.7, 22 | max_tokens: request.maxTokens || 800, 23 | stream: false, 24 | }), 25 | }); 26 | 27 | if (!response.ok) { 28 | const errorData = await response.json().catch(() => ({})); 29 | 30 | // Handle common Ollama connection errors 31 | if (response.status === 0 || !response.status) { 32 | throw this.createError( 33 | 'Cannot connect to Ollama. Make sure Ollama is running on your machine.', 34 | 'CONNECTION_ERROR', 35 | undefined, 36 | true 37 | ); 38 | } 39 | 40 | throw this.createError( 41 | `Ollama API error: ${response.statusText}. ${errorData.error?.message || ''}`, 42 | errorData.error?.code || 'API_ERROR', 43 | response.status 44 | ); 45 | } 46 | 47 | const data = await response.json(); 48 | 49 | if (!data.choices || !data.choices[0] || !data.choices[0].message) { 50 | throw this.createError('Invalid response format from Ollama', 'INVALID_RESPONSE'); 51 | } 52 | 53 | return { 54 | content: data.choices[0].message.content, 55 | usage: data.usage ? { 56 | promptTokens: data.usage.prompt_tokens || 0, 57 | completionTokens: data.usage.completion_tokens || 0, 58 | totalTokens: data.usage.total_tokens || 0, 59 | } : undefined, 60 | model: data.model || request.model, 61 | finishReason: data.choices[0].finish_reason, 62 | }; 63 | } catch (error) { 64 | if (error instanceof Error && error.name === 'AIError') { 65 | throw error; 66 | } 67 | 68 | // Handle network errors specifically for Ollama 69 | if (error instanceof TypeError && error.message.includes('fetch')) { 70 | throw this.createError( 71 | 'Cannot connect to Ollama. Make sure Ollama is running and accessible.', 72 | 'CONNECTION_ERROR', 73 | undefined, 74 | true 75 | ); 76 | } 77 | 78 | throw this.createError( 79 | `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`, 80 | 'NETWORK_ERROR', 81 | undefined, 82 | true 83 | ); 84 | } 85 | } 86 | 87 | private createError( 88 | message: string, 89 | code?: string, 90 | statusCode?: number, 91 | retryable = false 92 | ): AIError { 93 | const error = new Error(message) as AIError; 94 | error.name = 'AIError'; 95 | error.provider = 'ollama'; 96 | error.code = code; 97 | error.statusCode = statusCode; 98 | error.retryable = retryable; 99 | return error; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/core/services/ai/providers/anthropic.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Anthropic Claude Provider Implementation 3 | */ 4 | 5 | import { AIRequest, AIResponse, AIError } from '../../../types/ai.types'; 6 | 7 | export class AnthropicProvider { 8 | private baseUrl = 'https://api.anthropic.com/v1'; 9 | 10 | async generateCompletion(request: AIRequest): Promise { 11 | if (!request.apiKey) { 12 | throw this.createError('API key is required for Anthropic', 'MISSING_API_KEY'); 13 | } 14 | 15 | try { 16 | // Convert messages to Anthropic format 17 | const { system, messages } = this.convertMessagesToAnthropicFormat(request.messages); 18 | 19 | const response = await fetch(`${request.baseUrl || this.baseUrl}/messages`, { 20 | method: 'POST', 21 | headers: { 22 | 'Content-Type': 'application/json', 23 | 'x-api-key': request.apiKey, 24 | 'anthropic-version': '2023-06-01', 25 | ...request.customHeaders, 26 | }, 27 | body: JSON.stringify({ 28 | model: request.model, 29 | max_tokens: request.maxTokens || 800, 30 | temperature: request.temperature || 0.7, 31 | system, 32 | messages, 33 | }), 34 | }); 35 | 36 | if (!response.ok) { 37 | const errorData = await response.json().catch(() => ({})); 38 | throw this.createError( 39 | `Anthropic API error: ${response.statusText}. ${errorData.error?.message || ''}`, 40 | errorData.error?.type || 'API_ERROR', 41 | response.status 42 | ); 43 | } 44 | 45 | const data = await response.json(); 46 | 47 | if (!data.content || !data.content[0] || !data.content[0].text) { 48 | throw this.createError('Invalid response format from Anthropic', 'INVALID_RESPONSE'); 49 | } 50 | 51 | return { 52 | content: data.content[0].text, 53 | usage: data.usage ? { 54 | promptTokens: data.usage.input_tokens, 55 | completionTokens: data.usage.output_tokens, 56 | totalTokens: data.usage.input_tokens + data.usage.output_tokens, 57 | } : undefined, 58 | model: data.model, 59 | finishReason: data.stop_reason, 60 | }; 61 | } catch (error) { 62 | if (error instanceof Error && error.name === 'AIError') { 63 | throw error; 64 | } 65 | 66 | throw this.createError( 67 | `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`, 68 | 'NETWORK_ERROR', 69 | undefined, 70 | true 71 | ); 72 | } 73 | } 74 | 75 | private convertMessagesToAnthropicFormat(messages: AIRequest['messages']) { 76 | let system = ''; 77 | const anthropicMessages = []; 78 | 79 | for (const message of messages) { 80 | if (message.role === 'system') { 81 | system += message.content + '\n'; 82 | } else { 83 | anthropicMessages.push({ 84 | role: message.role, 85 | content: message.content, 86 | }); 87 | } 88 | } 89 | 90 | return { system: system.trim(), messages: anthropicMessages }; 91 | } 92 | 93 | private createError( 94 | message: string, 95 | code?: string, 96 | statusCode?: number, 97 | retryable = false 98 | ): AIError { 99 | const error = new Error(message) as AIError; 100 | error.name = 'AIError'; 101 | error.provider = 'anthropic'; 102 | error.code = code; 103 | error.statusCode = statusCode; 104 | error.retryable = retryable; 105 | return error; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/core/types/ai.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Core AI types for multi-provider support 3 | */ 4 | 5 | export type AIProvider = 'openai' | 'gemini' | 'anthropic' | 'deepseek' | 'ollama' | 'browser'; 6 | 7 | export interface AIModel { 8 | id: string; 9 | name: string; 10 | description: string; 11 | contextWindow: number; 12 | costTier: 'free' | 'low' | 'medium' | 'high'; 13 | capabilities: { 14 | multipleChoice: boolean; 15 | dissertative: boolean; 16 | evaluation: boolean; 17 | reasoning: boolean; 18 | codeGeneration: boolean; 19 | }; 20 | recommended?: boolean; 21 | } 22 | 23 | export interface AIProviderConfig { 24 | id: AIProvider; 25 | name: string; 26 | description: string; 27 | requiresApiKey: boolean; 28 | apiKeyLabel: string; 29 | apiKeyPlaceholder: string; 30 | setupInstructions: string[]; 31 | models: AIModel[]; 32 | defaultModel: string; 33 | baseUrl?: string; 34 | headers?: Record; 35 | maxTokens: { 36 | multipleChoice: number; 37 | dissertative: number; 38 | evaluation: number; 39 | }; 40 | temperature: { 41 | multipleChoice: number; 42 | dissertative: number; 43 | evaluation: number; 44 | }; 45 | } 46 | 47 | export interface AIProviderSettings { 48 | provider: AIProvider; 49 | apiKey: string; 50 | model: string; 51 | baseUrl?: string; 52 | customHeaders?: Record; 53 | } 54 | 55 | export interface AIRequest { 56 | provider: AIProvider; 57 | model: string; 58 | messages: AIMessage[]; 59 | temperature?: number; 60 | maxTokens?: number; 61 | apiKey?: string; 62 | baseUrl?: string; 63 | customHeaders?: Record; 64 | } 65 | 66 | export interface AIMessage { 67 | role: 'system' | 'user' | 'assistant'; 68 | content: string; 69 | } 70 | 71 | export interface AIResponse { 72 | content: string; 73 | usage?: { 74 | promptTokens: number; 75 | completionTokens: number; 76 | totalTokens: number; 77 | }; 78 | model?: string; 79 | finishReason?: string; 80 | } 81 | 82 | export interface QuestionGenerationRequest { 83 | contexts: string[]; // Minimum 1 context required (replaces subject) 84 | type: 'multipleChoice' | 'dissertative' | 'evaluation'; 85 | language: string; 86 | instructions?: string[]; 87 | previousQuestions?: string[]; 88 | difficulty?: 'easy' | 'medium' | 'hard'; 89 | customPrompt?: string; 90 | } 91 | 92 | export interface GeneratedQuestion { 93 | question: string; 94 | type: 'multipleChoice' | 'dissertative'; 95 | options?: string[]; 96 | correctAnswer?: number; 97 | explanation?: string; 98 | sampleAnswer?: string; 99 | evaluationCriteria?: string[]; 100 | difficulty?: 'easy' | 'medium' | 'hard'; 101 | metadata?: { 102 | provider: AIProvider; 103 | model: string; 104 | tokensUsed?: number; 105 | generationTime?: number; 106 | }; 107 | } 108 | 109 | export interface AnswerEvaluationRequest { 110 | question: string; 111 | userAnswer: string; 112 | correctAnswer?: string; 113 | type: 'multipleChoice' | 'dissertative'; 114 | language: string; 115 | customPrompt?: string; 116 | } 117 | 118 | export interface AnswerEvaluation { 119 | isCorrect: boolean; 120 | score: number; // 0-100 121 | feedback: string; 122 | suggestions?: string[]; 123 | metadata?: { 124 | provider: AIProvider; 125 | model: string; 126 | tokensUsed?: number; 127 | evaluationTime?: number; 128 | }; 129 | } 130 | 131 | export interface AIError extends Error { 132 | provider: AIProvider; 133 | code?: string; 134 | statusCode?: number; 135 | retryable?: boolean; 136 | } 137 | -------------------------------------------------------------------------------- /src/hooks/useTheme.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | import { useLocalStorage } from './useLocalStorage'; 3 | import { Theme, ThemeConfig } from '../core/types'; 4 | import { 5 | themes, 6 | getAllThemes, 7 | getThemesByCategory, 8 | getTheme 9 | } from '../core/services/theme'; 10 | import { STORAGE_KEYS, DEFAULTS } from '../core/config/constants'; 11 | 12 | /** 13 | * Hook for managing application theme 14 | */ 15 | export function useTheme() { 16 | // Load theme from localStorage 17 | const [currentTheme, setCurrentTheme] = useLocalStorage( 18 | STORAGE_KEYS.THEME, 19 | DEFAULTS.THEME as Theme 20 | ); 21 | 22 | // Local state for theme config 23 | const [themeConfig, setThemeConfig] = useState(getTheme(currentTheme)); 24 | 25 | // Apply theme to document when it changes 26 | useEffect(() => { 27 | const config = getTheme(currentTheme); 28 | setThemeConfig(config); 29 | 30 | // Apply CSS custom properties 31 | const root = document.documentElement; 32 | 33 | // Colors 34 | Object.entries(config.colors).forEach(([key, value]) => { 35 | root.style.setProperty(`--color-${key}`, value); 36 | }); 37 | 38 | // Gradients 39 | Object.entries(config.gradients).forEach(([key, value]) => { 40 | root.style.setProperty(`--gradient-${key}`, value); 41 | }); 42 | 43 | // Shadows 44 | Object.entries(config.shadows).forEach(([key, value]) => { 45 | root.style.setProperty(`--shadow-${key}`, value); 46 | }); 47 | 48 | // Effects 49 | Object.entries(config.effects).forEach(([key, value]) => { 50 | root.style.setProperty(`--effect-${key}`, value); 51 | }); 52 | 53 | // Add theme class to body 54 | document.body.className = document.body.className.replace(/theme-\w+/g, ''); 55 | document.body.classList.add(`theme-${currentTheme}`); 56 | 57 | // Update meta theme-color 58 | const metaThemeColor = document.querySelector('meta[name="theme-color"]'); 59 | if (metaThemeColor) { 60 | metaThemeColor.setAttribute('content', config.colors.primary); 61 | } 62 | 63 | }, [currentTheme]); 64 | 65 | /** 66 | * Change the current theme 67 | */ 68 | const changeTheme = useCallback((newTheme: Theme) => { 69 | setCurrentTheme(newTheme); 70 | 71 | // Use a more reliable refresh method that always goes to home page 72 | setTimeout(() => { 73 | try { 74 | // Always redirect to home page to avoid 404 errors 75 | const baseUrl = window.location.origin; 76 | const homeUrl = `${baseUrl}/`; 77 | 78 | // Use window.location.href for the most reliable redirect 79 | window.location.href = homeUrl; 80 | } catch (error) { 81 | console.error('Error during theme change redirect:', error); 82 | 83 | // Fallback: try to reload the current page 84 | try { 85 | window.location.reload(); 86 | } catch (fallbackError) { 87 | console.error('Fallback reload also failed:', fallbackError); 88 | 89 | // Last resort: force navigation to home 90 | try { 91 | window.location.replace('/'); 92 | } catch (lastResortError) { 93 | console.error('All redirect methods failed:', lastResortError); 94 | } 95 | } 96 | } 97 | }, 100); 98 | }, [setCurrentTheme]); 99 | 100 | return { 101 | currentTheme, 102 | themeConfig, 103 | changeTheme, 104 | getThemesByCategory, 105 | getAllThemes, 106 | themes, 107 | }; 108 | } 109 | 110 | export type { Theme, ThemeConfig }; 111 | -------------------------------------------------------------------------------- /public/sw.js: -------------------------------------------------------------------------------- 1 | // Lightweight Service Worker optimized for fast initial load 2 | const CACHE_NAME = 'studorama-v2.6.3'; 3 | const ESSENTIAL_FILES = [ 4 | '/', 5 | '/index.html', 6 | '/manifest.json' 7 | ]; 8 | 9 | // Install event - only cache essential files 10 | self.addEventListener('install', (event) => { 11 | console.log('Service Worker: Installing...'); 12 | 13 | // Skip waiting to activate immediately 14 | self.skipWaiting(); 15 | 16 | // Only cache essential files to speed up install 17 | event.waitUntil( 18 | caches.open(CACHE_NAME) 19 | .then((cache) => { 20 | console.log('Service Worker: Caching essential files'); 21 | return cache.addAll(ESSENTIAL_FILES); 22 | }) 23 | .catch((error) => { 24 | console.error('Service Worker: Error caching essential files', error); 25 | }) 26 | ); 27 | }); 28 | 29 | // Activate event - clean up old caches quickly 30 | self.addEventListener('activate', (event) => { 31 | console.log('Service Worker: Activating...'); 32 | 33 | event.waitUntil( 34 | Promise.all([ 35 | // Clean up old caches 36 | caches.keys().then((cacheNames) => { 37 | return Promise.all( 38 | cacheNames.map((cacheName) => { 39 | if (cacheName !== CACHE_NAME) { 40 | console.log('Service Worker: Deleting old cache', cacheName); 41 | return caches.delete(cacheName); 42 | } 43 | }) 44 | ); 45 | }), 46 | // Take control of all clients immediately 47 | self.clients.claim() 48 | ]) 49 | ); 50 | }); 51 | 52 | // Fetch event - minimal caching strategy for performance 53 | self.addEventListener('fetch', (event) => { 54 | const { request } = event; 55 | const url = new URL(request.url); 56 | 57 | // Skip non-GET requests 58 | if (request.method !== 'GET') { 59 | return; 60 | } 61 | 62 | // Skip chrome-extension and other non-http requests 63 | if (!url.protocol.startsWith('http')) { 64 | return; 65 | } 66 | 67 | // Skip development server specific requests 68 | if (url.pathname.includes('/node_modules/') || 69 | url.pathname.includes('/@vite/') || 70 | url.pathname.includes('/@fs/') || 71 | url.pathname.includes('/src/') || 72 | url.searchParams.has('v') || 73 | url.pathname.endsWith('.map')) { 74 | return; 75 | } 76 | 77 | // Only handle essential files and same-origin requests 78 | if (url.origin === location.origin) { 79 | // Network first strategy for better performance and fresh content 80 | event.respondWith( 81 | fetch(request) 82 | .then((response) => { 83 | // Only cache successful responses for essential files 84 | if (response.ok && ESSENTIAL_FILES.includes(url.pathname)) { 85 | const responseClone = response.clone(); 86 | caches.open(CACHE_NAME) 87 | .then((cache) => { 88 | cache.put(request, responseClone); 89 | }) 90 | .catch(() => { 91 | // Ignore cache errors to avoid blocking 92 | }); 93 | } 94 | return response; 95 | }) 96 | .catch(() => { 97 | // Fallback to cache only for essential files 98 | if (ESSENTIAL_FILES.includes(url.pathname)) { 99 | return caches.match(request); 100 | } 101 | throw new Error('Network error and no cache available'); 102 | }) 103 | ); 104 | } 105 | }); 106 | 107 | // Simplified message handling 108 | self.addEventListener('message', (event) => { 109 | if (event.data && event.data.type === 'SKIP_WAITING') { 110 | self.skipWaiting(); 111 | } 112 | }); 113 | 114 | // Minimal error handling 115 | self.addEventListener('error', (event) => { 116 | console.error('Service Worker: Error occurred', event.error); 117 | }); -------------------------------------------------------------------------------- /src/core/services/ai/providers/browser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Browser AI Provider Implementation (Experimental) 3 | */ 4 | 5 | import { AIRequest, AIResponse, AIError } from '../../../types/ai.types'; 6 | 7 | export class BrowserAIProvider { 8 | async generateCompletion(request: AIRequest): Promise { 9 | try { 10 | // Check if browser AI is available 11 | if (!this.isBrowserAIAvailable()) { 12 | throw this.createError( 13 | 'Browser AI is not available. This feature requires experimental web features.', 14 | 'NOT_AVAILABLE' 15 | ); 16 | } 17 | 18 | // This is a placeholder implementation 19 | // In a real implementation, this would use browser-based AI APIs 20 | // such as the proposed WebNN API or similar browser AI capabilities 21 | 22 | const mockResponse = this.generateMockResponse(request); 23 | 24 | return { 25 | content: mockResponse, 26 | usage: { 27 | promptTokens: 50, 28 | completionTokens: 100, 29 | totalTokens: 150, 30 | }, 31 | model: request.model, 32 | finishReason: 'stop', 33 | }; 34 | } catch (error) { 35 | if (error instanceof Error && error.name === 'AIError') { 36 | throw error; 37 | } 38 | 39 | throw this.createError( 40 | `Browser AI error: ${error instanceof Error ? error.message : 'Unknown error'}`, 41 | 'BROWSER_ERROR' 42 | ); 43 | } 44 | } 45 | 46 | private isBrowserAIAvailable(): boolean { 47 | // Check for experimental browser AI features 48 | // This is a placeholder - actual implementation would check for real APIs 49 | return typeof window !== 'undefined' && 'ai' in window; 50 | } 51 | 52 | private generateMockResponse(request: AIRequest): string { 53 | // This is a mock implementation for demonstration 54 | // In a real implementation, this would use actual browser AI capabilities 55 | 56 | const lastMessage = request.messages[request.messages.length - 1]; 57 | const content = lastMessage?.content || ''; 58 | 59 | if (content.includes('multiple choice') || content.includes('múltipla escolha')) { 60 | return `\`\`\`json 61 | { 62 | "question": "**Sample Question**: This is a demonstration question from Browser AI.", 63 | "options": [ 64 | "Option A - This is the first option", 65 | "Option B - This is the second option", 66 | "Option C - This is the third option", 67 | "Option D - This is the fourth option" 68 | ], 69 | "correctAnswer": 1, 70 | "explanation": "**Explanation**: This is a mock response from Browser AI. In a real implementation, this would be generated by local AI models.", 71 | "difficulty": "medium" 72 | } 73 | \`\`\``; 74 | } 75 | 76 | if (content.includes('dissertative') || content.includes('dissertativa')) { 77 | return `\`\`\`json 78 | { 79 | "question": "**Sample Dissertative Question**: This is a demonstration question that requires detailed analysis.", 80 | "sampleAnswer": "**Sample Answer**: This would be a comprehensive answer demonstrating the expected depth and structure.", 81 | "evaluationCriteria": [ 82 | "Clear understanding of concepts", 83 | "Logical reasoning and structure", 84 | "Use of relevant examples" 85 | ], 86 | "difficulty": "medium" 87 | } 88 | \`\`\``; 89 | } 90 | 91 | return "This is a mock response from Browser AI. The actual implementation would use local AI models running in the browser."; 92 | } 93 | 94 | private createError( 95 | message: string, 96 | code?: string, 97 | statusCode?: number, 98 | retryable = false 99 | ): AIError { 100 | const error = new Error(message) as AIError; 101 | error.name = 'AIError'; 102 | error.provider = 'browser'; 103 | error.code = code; 104 | error.statusCode = statusCode; 105 | error.retryable = retryable; 106 | return error; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/core/services/ai/providers/gemini.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Google Gemini Provider Implementation 3 | */ 4 | 5 | import { AIRequest, AIResponse, AIError } from '../../../types/ai.types'; 6 | 7 | export class GeminiProvider { 8 | private baseUrl = 'https://generativelanguage.googleapis.com/v1beta'; 9 | 10 | async generateCompletion(request: AIRequest): Promise { 11 | if (!request.apiKey) { 12 | throw this.createError('API key is required for Gemini', 'MISSING_API_KEY'); 13 | } 14 | 15 | try { 16 | // Convert messages to Gemini format 17 | const contents = this.convertMessagesToGeminiFormat(request.messages); 18 | 19 | const response = await fetch( 20 | `${request.baseUrl || this.baseUrl}/models/${request.model}:generateContent?key=${request.apiKey}`, 21 | { 22 | method: 'POST', 23 | headers: { 24 | 'Content-Type': 'application/json', 25 | ...request.customHeaders, 26 | }, 27 | body: JSON.stringify({ 28 | contents, 29 | generationConfig: { 30 | temperature: request.temperature || 0.7, 31 | maxOutputTokens: request.maxTokens || 800, 32 | topP: 0.8, 33 | topK: 40, 34 | }, 35 | safetySettings: [ 36 | { 37 | category: 'HARM_CATEGORY_HARASSMENT', 38 | threshold: 'BLOCK_MEDIUM_AND_ABOVE' 39 | }, 40 | { 41 | category: 'HARM_CATEGORY_HATE_SPEECH', 42 | threshold: 'BLOCK_MEDIUM_AND_ABOVE' 43 | }, 44 | { 45 | category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', 46 | threshold: 'BLOCK_MEDIUM_AND_ABOVE' 47 | }, 48 | { 49 | category: 'HARM_CATEGORY_DANGEROUS_CONTENT', 50 | threshold: 'BLOCK_MEDIUM_AND_ABOVE' 51 | } 52 | ], 53 | }), 54 | } 55 | ); 56 | 57 | if (!response.ok) { 58 | const errorData = await response.json().catch(() => ({})); 59 | throw this.createError( 60 | `Gemini API error: ${response.statusText}. ${errorData.error?.message || ''}`, 61 | errorData.error?.code || 'API_ERROR', 62 | response.status 63 | ); 64 | } 65 | 66 | const data = await response.json(); 67 | 68 | if (!data.candidates || !data.candidates[0] || !data.candidates[0].content) { 69 | throw this.createError('Invalid response format from Gemini', 'INVALID_RESPONSE'); 70 | } 71 | 72 | const candidate = data.candidates[0]; 73 | const content = candidate.content.parts[0]?.text || ''; 74 | 75 | return { 76 | content, 77 | usage: data.usageMetadata ? { 78 | promptTokens: data.usageMetadata.promptTokenCount, 79 | completionTokens: data.usageMetadata.candidatesTokenCount, 80 | totalTokens: data.usageMetadata.totalTokenCount, 81 | } : undefined, 82 | model: request.model, 83 | finishReason: candidate.finishReason, 84 | }; 85 | } catch (error) { 86 | if (error instanceof Error && error.name === 'AIError') { 87 | throw error; 88 | } 89 | 90 | throw this.createError( 91 | `Network error: ${error instanceof Error ? error.message : 'Unknown error'}`, 92 | 'NETWORK_ERROR', 93 | undefined, 94 | true 95 | ); 96 | } 97 | } 98 | 99 | private convertMessagesToGeminiFormat(messages: AIRequest['messages']) { 100 | const contents = []; 101 | let systemInstruction = ''; 102 | 103 | for (const message of messages) { 104 | if (message.role === 'system') { 105 | systemInstruction += message.content + '\n'; 106 | } else { 107 | contents.push({ 108 | role: message.role === 'assistant' ? 'model' : 'user', 109 | parts: [{ text: message.content }], 110 | }); 111 | } 112 | } 113 | 114 | // If we have system instructions, prepend them to the first user message 115 | if (systemInstruction && contents.length > 0 && contents[0].role === 'user') { 116 | contents[0].parts[0].text = systemInstruction + '\n' + contents[0].parts[0].text; 117 | } 118 | 119 | return contents; 120 | } 121 | 122 | private createError( 123 | message: string, 124 | code?: string, 125 | statusCode?: number, 126 | retryable = false 127 | ): AIError { 128 | const error = new Error(message) as AIError; 129 | error.name = 'AIError'; 130 | error.provider = 'gemini'; 131 | error.code = code; 132 | error.statusCode = statusCode; 133 | error.retryable = retryable; 134 | return error; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | // custom-types.d.ts 2 | import 'react'; 3 | 4 | declare module 'react' { 5 | interface CSSProperties { 6 | [key: `--${string}`]: string | number; 7 | } 8 | interface HTMLAttributes { 9 | inline?: boolean; 10 | } 11 | } 12 | 13 | export interface StudySession { 14 | id: string; 15 | contexts: string[]; // List of study contexts (minimum 1) 16 | instructions?: string[]; // Optional special instructions 17 | createdAt: string; 18 | questions: Question[]; 19 | status: 'active' | 'completed'; 20 | score: number; 21 | totalQuestions: number; 22 | questionType?: 'multipleChoice' | 'dissertative' | 'mixed'; 23 | learningSettings?: LearningSettings; 24 | spacedRepetition?: SpacedRepetitionData; 25 | currentQuestionIndex?: number; // Track which question the user was on 26 | preloadedQuestions?: Question[]; // Questions loaded ahead of time 27 | enableLatex?: boolean; // LaTeX visualization option 28 | enableCodeVisualization?: boolean; // Code visualization option 29 | 30 | // Enhanced session state preservation 31 | sessionState?: SessionState; 32 | 33 | // Session history tracking 34 | contextsHistory?: ContextsHistoryEntry[]; 35 | instructionHistory?: InstructionHistoryEntry[]; 36 | learningSettingsHistory?: LearningSettingsHistoryEntry[]; 37 | } 38 | 39 | export interface SessionState { 40 | currentUserAnswer?: string | number; 41 | currentConfidence?: number; 42 | showFeedback?: boolean; 43 | showElaborative?: boolean; 44 | showSelfExplanation?: boolean; 45 | elaborativeQuestion?: string; 46 | elaborativeAnswer?: string; 47 | selfExplanationAnswer?: string; 48 | isEvaluating?: boolean; 49 | lastSavedAt: string; 50 | } 51 | 52 | export interface ContextsHistoryEntry { 53 | id: string; 54 | previousContexts: string[]; 55 | newContexts: string[]; 56 | changedAt: string; 57 | reason?: string; 58 | } 59 | 60 | export interface InstructionHistoryEntry { 61 | id: string; 62 | action: 'added' | 'removed'; 63 | instruction: string; 64 | previousValue?: string; 65 | changedAt: string; 66 | } 67 | 68 | export interface LearningSettingsHistoryEntry { 69 | id: string; 70 | previousSettings: LearningSettings; 71 | newSettings: LearningSettings; 72 | changedAt: string; 73 | } 74 | 75 | export interface Question { 76 | id: string; 77 | question: string; 78 | options?: string[]; 79 | correctAnswer?: number; 80 | userAnswer?: number | string; 81 | isCorrect?: boolean; 82 | attempts: number; 83 | feedback?: string; 84 | type: 'multipleChoice' | 'dissertative'; 85 | correctAnswerText?: string; 86 | aiEvaluation?: string; 87 | difficulty?: 'easy' | 'medium' | 'hard'; 88 | reviewCount?: number; 89 | lastReviewed?: string; 90 | nextReview?: string; 91 | confidence?: number; // 1-5 scale 92 | retrievalStrength?: number; // How well the user knows this 93 | isPreloaded?: boolean; // Mark if this question was preloaded 94 | 95 | // Question timing 96 | timeSpent?: number; // in ms 97 | startedAt?: string; 98 | completedAt?: string; 99 | timedOut?: boolean; 100 | } 101 | 102 | export interface LearningSettings { 103 | spacedRepetition: boolean; 104 | interleaving: boolean; 105 | elaborativeInterrogation: boolean; 106 | selfExplanation: boolean; 107 | desirableDifficulties: boolean; 108 | retrievalPractice: boolean; 109 | generationEffect: boolean; 110 | } 111 | 112 | export interface LearningTechniquesPreference { 113 | rememberChoice: boolean; 114 | defaultSettings: LearningSettings; 115 | } 116 | 117 | export interface SpacedRepetitionData { 118 | reviewIntervals: number[]; // Days between reviews 119 | currentInterval: number; 120 | easeFactor: number; 121 | reviewCount: number; 122 | } 123 | 124 | export interface APISettings { 125 | openaiApiKey: string; 126 | model: string; 127 | customPrompts: { 128 | multipleChoice: string; 129 | dissertative: string; 130 | evaluation: string; 131 | elaborativePrompt: string; 132 | retrievalPrompt: string; 133 | }; 134 | preloadQuestions?: number; // Number of questions to preload 135 | } 136 | 137 | export interface PreloadingSettings { 138 | preloadQuestions: number; 139 | enableBackgroundLoading: boolean; 140 | } 141 | 142 | export type Language = 'en-US' | 'pt-BR'; 143 | 144 | export interface LanguageSettings { 145 | language: Language; 146 | } 147 | 148 | // Timer preferences stored globally with auto-save 149 | export interface TimerPreferences { 150 | rememberChoice: boolean; 151 | defaultSessionTimerEnabled: boolean; 152 | defaultSessionTimer: number; // in minutes 153 | defaultQuestionTimerEnabled: boolean; 154 | defaultQuestionTimer: number; // in seconds 155 | defaultAccumulateTime: boolean; 156 | defaultShowWarnings: boolean; 157 | defaultAutoSubmit: boolean; 158 | soundEnabled: boolean; 159 | vibrationEnabled: boolean; 160 | } 161 | -------------------------------------------------------------------------------- /scripts/deploy.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Deployment script with automatic version bumping 5 | */ 6 | 7 | import { execSync } from 'child_process'; 8 | import VersionManager from './version-manager.js'; 9 | 10 | class DeployManager { 11 | constructor() { 12 | this.versionManager = new VersionManager(); 13 | } 14 | 15 | /** 16 | * Execute shell command with error handling 17 | */ 18 | exec(command, options = {}) { 19 | try { 20 | console.log(`🔧 Running: ${command}`); 21 | const result = execSync(command, { 22 | stdio: 'inherit', 23 | encoding: 'utf8', 24 | ...options 25 | }); 26 | return result; 27 | } catch (error) { 28 | console.error(`❌ Command failed: ${command}`); 29 | console.error(error.message); 30 | process.exit(1); 31 | } 32 | } 33 | 34 | /** 35 | * Pre-deployment checks 36 | */ 37 | preDeploymentChecks() { 38 | console.log('🔍 Running pre-deployment checks...'); 39 | 40 | // Check if we're in a git repository 41 | try { 42 | this.exec('git status', { stdio: 'pipe' }); 43 | } catch (error) { 44 | console.error('❌ Not in a git repository'); 45 | process.exit(1); 46 | } 47 | 48 | // Check for uncommitted changes 49 | try { 50 | const status = execSync('git status --porcelain', { encoding: 'utf8' }); 51 | if (status.trim()) { 52 | console.warn('⚠️ You have uncommitted changes:'); 53 | console.log(status); 54 | 55 | // Ask user if they want to continue 56 | const readline = require('readline').createInterface({ 57 | input: process.stdin, 58 | output: process.stdout 59 | }); 60 | 61 | return new Promise((resolve) => { 62 | readline.question('Continue with deployment? (y/N): ', (answer) => { 63 | readline.close(); 64 | if (answer.toLowerCase() !== 'y') { 65 | console.log('❌ Deployment cancelled'); 66 | process.exit(1); 67 | } 68 | resolve(); 69 | }); 70 | }); 71 | } 72 | } catch (error) { 73 | // Git status failed, continue anyway 74 | } 75 | 76 | console.log('✅ Pre-deployment checks passed'); 77 | } 78 | 79 | /** 80 | * Build the application 81 | */ 82 | build() { 83 | console.log('🏗️ Building application...'); 84 | this.exec('npm install'); 85 | this.exec('npm run build'); 86 | console.log('✅ Build completed'); 87 | } 88 | 89 | /** 90 | * Deploy to production 91 | */ 92 | async deploy(versionType = 'patch', skipBuild = false) { 93 | console.log('🚀 Starting deployment process...'); 94 | 95 | // Pre-deployment checks 96 | await this.preDeploymentChecks(); 97 | 98 | // Bump version 99 | const newVersion = this.versionManager.bump(versionType); 100 | 101 | // Build application (unless skipped) 102 | if (!skipBuild) { 103 | this.build(); 104 | } 105 | 106 | // Commit version changes 107 | try { 108 | this.exec('git add .'); 109 | this.exec(`git commit -m "chore: bump version to ${newVersion}"`); 110 | this.exec(`git tag -a v${newVersion} -m "Release version ${newVersion}"`); 111 | console.log(`✅ Created git tag v${newVersion}`); 112 | } catch (error) { 113 | console.warn('⚠️ Could not create git commit/tag (this is optional)'); 114 | } 115 | 116 | // Push to repository (optional) 117 | try { 118 | this.exec('git push origin main'); 119 | this.exec('git push origin --tags'); 120 | console.log('✅ Pushed changes to repository'); 121 | } catch (error) { 122 | console.warn('⚠️ Could not push to repository (this is optional)'); 123 | } 124 | 125 | console.log(`🎉 Deployment completed! Version: ${newVersion}`); 126 | console.log(`📦 Build files are ready in the 'dist' directory`); 127 | 128 | return newVersion; 129 | } 130 | } 131 | 132 | // CLI Interface 133 | async function main() { 134 | const args = process.argv.slice(2); 135 | const command = args[0] || 'deploy'; 136 | const versionType = args[1] || 'patch'; 137 | const skipBuild = args.includes('--skip-build'); 138 | 139 | const deployManager = new DeployManager(); 140 | 141 | switch (command) { 142 | case 'deploy': 143 | await deployManager.deploy(versionType, skipBuild); 144 | break; 145 | 146 | case 'build': 147 | deployManager.build(); 148 | break; 149 | 150 | default: 151 | console.log(` 152 | 🚀 Studorama Deploy Manager 153 | 154 | Usage: 155 | node scripts/deploy.js [command] [version-type] [options] 156 | 157 | Commands: 158 | deploy [type] Deploy with version bump (patch, minor, major) 159 | build Build application only 160 | 161 | Options: 162 | --skip-build Skip the build step during deployment 163 | 164 | Examples: 165 | node scripts/deploy.js deploy patch 166 | node scripts/deploy.js deploy minor 167 | node scripts/deploy.js build 168 | node scripts/deploy.js deploy major --skip-build 169 | `); 170 | break; 171 | } 172 | } 173 | 174 | // Run if called directly 175 | if (import.meta.url === `file://${process.argv[1]}`) { 176 | main().catch(console.error); 177 | } 178 | 179 | export default DeployManager; -------------------------------------------------------------------------------- /src/components/ui/IconButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { DivideIcon as LucideIcon } from 'lucide-react'; 3 | import { useTheme } from '../../hooks'; 4 | 5 | export interface IconButtonProps { 6 | icon: typeof LucideIcon; 7 | onClick?: () => void; 8 | variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'success' | 'warning' | 'info'; 9 | size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'; 10 | disabled?: boolean; 11 | loading?: boolean; 12 | className?: string; 13 | 'aria-label'?: string; 14 | title?: string; 15 | type?: 'button' | 'submit' | 'reset'; 16 | style?: React.CSSProperties; 17 | } 18 | 19 | export default function IconButton({ 20 | icon: Icon, 21 | onClick, 22 | variant = 'ghost', 23 | size = 'md', 24 | disabled = false, 25 | loading = false, 26 | className = '', 27 | 'aria-label': ariaLabel, 28 | title, 29 | type = 'button', 30 | style, 31 | ...props 32 | }: IconButtonProps) { 33 | const { themeConfig } = useTheme(); 34 | 35 | // Size configurations with proper alignment 36 | const sizeConfig = { 37 | xs: { 38 | button: 'w-6 h-6 min-w-[24px] min-h-[24px]', 39 | icon: 'w-3 h-3', 40 | padding: 'p-1.5' 41 | }, 42 | sm: { 43 | button: 'w-8 h-8 min-w-[32px] min-h-[32px]', 44 | icon: 'w-4 h-4', 45 | padding: 'p-2' 46 | }, 47 | md: { 48 | button: 'w-10 h-10 min-w-[40px] min-h-[40px]', 49 | icon: 'w-5 h-5', 50 | padding: 'p-2.5' 51 | }, 52 | lg: { 53 | button: 'w-12 h-12 min-w-[48px] min-h-[48px]', 54 | icon: 'w-6 h-6', 55 | padding: 'p-3' 56 | }, 57 | xl: { 58 | button: 'w-14 h-14 min-w-[56px] min-h-[56px]', 59 | icon: 'w-7 h-7', 60 | padding: 'p-3.5' 61 | } 62 | }; 63 | 64 | // Variant configurations 65 | const variantConfig = { 66 | primary: { 67 | background: themeConfig.colors.primary, 68 | color: '#ffffff', 69 | hover: themeConfig.colors.primaryHover, 70 | border: 'transparent' 71 | }, 72 | secondary: { 73 | background: themeConfig.colors.secondary, 74 | color: '#ffffff', 75 | hover: themeConfig.colors.secondary + 'DD', 76 | border: 'transparent' 77 | }, 78 | ghost: { 79 | background: 'transparent', 80 | color: themeConfig.colors.textSecondary, 81 | hover: themeConfig.colors.surface, 82 | border: 'transparent' 83 | }, 84 | danger: { 85 | background: themeConfig.colors.error, 86 | color: '#ffffff', 87 | hover: themeConfig.colors.error + 'DD', 88 | border: 'transparent' 89 | }, 90 | success: { 91 | background: themeConfig.colors.success, 92 | color: '#ffffff', 93 | hover: themeConfig.colors.success + 'DD', 94 | border: 'transparent' 95 | }, 96 | warning: { 97 | background: themeConfig.colors.warning, 98 | color: '#ffffff', 99 | hover: themeConfig.colors.warning + 'DD', 100 | border: 'transparent' 101 | }, 102 | info: { 103 | background: themeConfig.colors.info, 104 | color: '#ffffff', 105 | hover: themeConfig.colors.info + 'DD', 106 | border: 'transparent' 107 | } 108 | }; 109 | 110 | const currentSize = sizeConfig[size]; 111 | const currentVariant = variantConfig[variant]; 112 | 113 | const buttonStyle: React.CSSProperties = { 114 | backgroundColor: currentVariant.background, 115 | color: currentVariant.color, 116 | borderColor: currentVariant.border, 117 | ...style 118 | }; 119 | 120 | const hoverStyle = ` 121 | &:hover:not(:disabled) { 122 | background-color: ${currentVariant.hover} !important; 123 | } 124 | `; 125 | 126 | return ( 127 | <> 128 | 129 | 180 | 181 | ); 182 | } -------------------------------------------------------------------------------- /src/core/services/notification/notificationService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Notification service for displaying user notifications 3 | */ 4 | 5 | export type NotificationType = 'success' | 'error' | 'info' | 'warning'; 6 | 7 | export interface NotificationOptions { 8 | type: NotificationType; 9 | title: string; 10 | message: string; 11 | duration?: number; 12 | position?: 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center'; 13 | onClose?: () => void; 14 | onClick?: () => void; 15 | } 16 | 17 | /** 18 | * Show a notification to the user 19 | */ 20 | export function showNotification({ 21 | type = 'info', 22 | title, 23 | message, 24 | duration = 5000, 25 | position = 'top-right', 26 | onClose, 27 | onClick 28 | }: NotificationOptions): (() => void) | undefined { 29 | try { 30 | // Color schemes for different notification types 31 | const colorSchemes = { 32 | success: { 33 | background: 'linear-gradient(135deg, #10b981 0%, #059669 100%)', 34 | icon: '✓' 35 | }, 36 | error: { 37 | background: 'linear-gradient(135deg, #ef4444 0%, #dc2626 100%)', 38 | icon: '⚠' 39 | }, 40 | info: { 41 | background: 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)', 42 | icon: 'ℹ' 43 | }, 44 | warning: { 45 | background: 'linear-gradient(135deg, #f59e0b 0%, #d97706 100%)', 46 | icon: '⚠' 47 | } 48 | }; 49 | 50 | const scheme = colorSchemes[type]; 51 | 52 | // Position styles 53 | const positionStyles = { 54 | 'top-right': 'top: 20px; right: 20px;', 55 | 'top-left': 'top: 20px; left: 20px;', 56 | 'bottom-right': 'bottom: 20px; right: 20px;', 57 | 'bottom-left': 'bottom: 20px; left: 20px;', 58 | 'top-center': 'top: 20px; left: 50%; transform: translateX(-50%);', 59 | 'bottom-center': 'bottom: 20px; left: 50%; transform: translateX(-50%);' 60 | }; 61 | 62 | // Create notification element 63 | const notification = document.createElement('div'); 64 | notification.style.cssText = ` 65 | position: fixed; 66 | ${positionStyles[position]} 67 | background: ${scheme.background}; 68 | color: white; 69 | padding: 16px 20px; 70 | border-radius: 12px; 71 | box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); 72 | z-index: 10000; 73 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 74 | font-size: 14px; 75 | font-weight: 500; 76 | max-width: 350px; 77 | animation: slideIn 0.3s ease-out; 78 | cursor: pointer; 79 | user-select: none; 80 | border: 1px solid rgba(255, 255, 255, 0.1); 81 | `; 82 | 83 | // Add animation keyframes if not already present 84 | if (!document.querySelector('#notification-styles')) { 85 | const style = document.createElement('style'); 86 | style.id = 'notification-styles'; 87 | style.textContent = ` 88 | @keyframes slideIn { 89 | from { 90 | transform: ${position.includes('right') ? 'translateX(100%)' : 91 | position.includes('left') ? 'translateX(-100%)' : 92 | position.includes('top') ? 'translateY(-100%)' : 93 | 'translateY(100%)'}; 94 | opacity: 0; 95 | } 96 | to { 97 | transform: ${position.includes('center') ? 'translateX(-50%)' : 'translateX(0)'}; 98 | opacity: 1; 99 | } 100 | } 101 | @keyframes slideOut { 102 | from { 103 | transform: ${position.includes('center') ? 'translateX(-50%)' : 'translateX(0)'}; 104 | opacity: 1; 105 | } 106 | to { 107 | transform: ${position.includes('right') ? 'translateX(100%)' : 108 | position.includes('left') ? 'translateX(-100%)' : 109 | position.includes('top') ? 'translateY(-100%)' : 110 | 'translateY(100%)'}; 111 | opacity: 0; 112 | } 113 | } 114 | `; 115 | document.head.appendChild(style); 116 | } 117 | 118 | notification.innerHTML = ` 119 |
120 |
121 | ${scheme.icon} 122 |
123 |
124 |
${title}
125 |
${message}
126 |
127 |
128 | `; 129 | 130 | document.body.appendChild(notification); 131 | 132 | // Auto-remove notification after specified duration 133 | const removeNotification = () => { 134 | notification.style.animation = 'slideOut 0.3s ease-in forwards'; 135 | setTimeout(() => { 136 | if (notification.parentNode) { 137 | notification.parentNode.removeChild(notification); 138 | if (onClose) onClose(); 139 | } 140 | }, 300); 141 | }; 142 | 143 | if (duration > 0) { 144 | setTimeout(removeNotification, duration); 145 | } 146 | 147 | // Allow manual dismissal by clicking 148 | notification.addEventListener('click', () => { 149 | if (onClick) onClick(); 150 | removeNotification(); 151 | }); 152 | 153 | // Return a function to manually close the notification 154 | return removeNotification; 155 | } catch (error) { 156 | console.error('Error showing notification:', error); 157 | } 158 | } -------------------------------------------------------------------------------- /public/sitemap.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | https://www.studorama.com/ 9 | 2024-12-20 10 | weekly 11 | 1.0 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | https://www.studorama.com/?lang=pt-BR 20 | 2024-12-20 21 | weekly 22 | 1.0 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | https://www.studorama.com/study 31 | 2024-12-20 32 | weekly 33 | 0.9 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | https://www.studorama.com/study?lang=pt-BR 42 | 2024-12-20 43 | weekly 44 | 0.9 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | https://www.studorama.com/history 53 | 2024-12-20 54 | weekly 55 | 0.7 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | https://www.studorama.com/history?lang=pt-BR 64 | 2024-12-20 65 | weekly 66 | 0.7 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | https://www.studorama.com/settings 75 | 2024-12-20 76 | monthly 77 | 0.6 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | https://www.studorama.com/settings?lang=pt-BR 86 | 2024-12-20 87 | monthly 88 | 0.6 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | https://www.studorama.com/pricing 97 | 2024-12-20 98 | monthly 99 | 0.5 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | https://www.studorama.com/pricing?lang=pt-BR 108 | 2024-12-20 109 | monthly 110 | 0.5 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | https://www.studorama.com/success 119 | 2024-12-20 120 | yearly 121 | 0.3 122 | 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /src/core/services/storage/localStorage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Enhanced localStorage service with error handling, type safety, and performance optimizations 3 | */ 4 | 5 | // Cache for frequently accessed items to reduce JSON parsing 6 | const cache = new Map(); 7 | const CACHE_TTL = 5000; // 5 seconds cache TTL 8 | 9 | /** 10 | * Check if a value is corrupted (like "[object Object]") 11 | */ 12 | function isCorruptedValue(item: string): boolean { 13 | return item === '[object Object]' || 14 | item === '[object Array]' || 15 | item === 'undefined' || 16 | item === 'null' || 17 | (item.startsWith('[object ') && item.endsWith(']')); 18 | } 19 | 20 | /** 21 | * Get an item from localStorage with type safety and caching 22 | */ 23 | export function getItem(key: string, defaultValue: T): T { 24 | try { 25 | // Check cache first 26 | const cached = cache.get(key); 27 | if (cached && Date.now() - cached.timestamp < CACHE_TTL) { 28 | return cached.value; 29 | } 30 | 31 | const item = window.localStorage.getItem(key); 32 | if (item === null) { 33 | return defaultValue; 34 | } 35 | 36 | // Check for corrupted data 37 | if (isCorruptedValue(item)) { 38 | console.warn(`Corrupted data detected for localStorage key "${key}": ${item}. Using default value.`); 39 | // Remove corrupted data 40 | window.localStorage.removeItem(key); 41 | return defaultValue; 42 | } 43 | 44 | const parsed = JSON.parse(item); 45 | 46 | // Update cache 47 | cache.set(key, { value: parsed, timestamp: Date.now() }); 48 | 49 | return parsed; 50 | } catch (error) { 51 | console.error(`Error reading localStorage key "${key}":`, error); 52 | 53 | // If parsing failed, try to clean up corrupted data 54 | try { 55 | const item = window.localStorage.getItem(key); 56 | if (item && isCorruptedValue(item)) { 57 | console.warn(`Removing corrupted localStorage key "${key}"`); 58 | window.localStorage.removeItem(key); 59 | } 60 | } catch (cleanupError) { 61 | console.error(`Error cleaning up corrupted localStorage key "${key}":`, cleanupError); 62 | } 63 | 64 | return defaultValue; 65 | } 66 | } 67 | 68 | /** 69 | * Set an item in localStorage with error handling and cache invalidation 70 | */ 71 | export function setItem(key: string, value: T): boolean { 72 | try { 73 | const serialized = JSON.stringify(value); 74 | 75 | // Validate that serialization worked correctly 76 | if (isCorruptedValue(serialized)) { 77 | console.error(`Attempted to store corrupted value for key "${key}": ${serialized}`); 78 | return false; 79 | } 80 | 81 | window.localStorage.setItem(key, serialized); 82 | 83 | // Update cache 84 | cache.set(key, { value, timestamp: Date.now() }); 85 | 86 | return true; 87 | } catch (error) { 88 | console.error(`Error setting localStorage key "${key}":`, error); 89 | 90 | // If quota exceeded, try to clear old cache entries 91 | if (error instanceof DOMException && error.code === 22) { 92 | console.warn('localStorage quota exceeded, clearing cache...'); 93 | cache.clear(); 94 | 95 | // Try again after clearing cache 96 | try { 97 | const serialized = JSON.stringify(value); 98 | if (!isCorruptedValue(serialized)) { 99 | window.localStorage.setItem(key, serialized); 100 | cache.set(key, { value, timestamp: Date.now() }); 101 | return true; 102 | } 103 | } catch (retryError) { 104 | console.error(`Retry failed for localStorage key "${key}":`, retryError); 105 | } 106 | } 107 | 108 | return false; 109 | } 110 | } 111 | 112 | /** 113 | * Remove an item from localStorage and cache 114 | */ 115 | export function removeItem(key: string): boolean { 116 | try { 117 | window.localStorage.removeItem(key); 118 | cache.delete(key); 119 | return true; 120 | } catch (error) { 121 | console.error(`Error removing localStorage key "${key}":`, error); 122 | return false; 123 | } 124 | } 125 | 126 | /** 127 | * Clear all items from localStorage and cache 128 | */ 129 | export function clearAll(): boolean { 130 | try { 131 | window.localStorage.clear(); 132 | cache.clear(); 133 | return true; 134 | } catch (error) { 135 | console.error('Error clearing localStorage:', error); 136 | return false; 137 | } 138 | } 139 | 140 | /** 141 | * Get all keys in localStorage 142 | */ 143 | export function getAllKeys(): string[] { 144 | try { 145 | const keys: string[] = []; 146 | for (let i = 0; i < localStorage.length; i++) { 147 | const key = localStorage.key(i); 148 | if (key) { 149 | keys.push(key); 150 | } 151 | } 152 | return keys; 153 | } catch (error) { 154 | console.error('Error getting localStorage keys:', error); 155 | return []; 156 | } 157 | } 158 | 159 | /** 160 | * Check if a key exists in localStorage 161 | */ 162 | export function hasKey(key: string): boolean { 163 | try { 164 | return localStorage.getItem(key) !== null; 165 | } catch (error) { 166 | console.error(`Error checking localStorage key "${key}":`, error); 167 | return false; 168 | } 169 | } 170 | 171 | /** 172 | * Get the size of localStorage in bytes 173 | */ 174 | export function getStorageSize(): number { 175 | try { 176 | let size = 0; 177 | for (let i = 0; i < localStorage.length; i++) { 178 | const key = localStorage.key(i); 179 | if (key) { 180 | const value = localStorage.getItem(key) || ''; 181 | size += key.length + value.length; 182 | } 183 | } 184 | return size; 185 | } catch (error) { 186 | console.error('Error calculating localStorage size:', error); 187 | return 0; 188 | } 189 | } 190 | 191 | /** 192 | * Clear cache manually (useful for testing or memory management) 193 | */ 194 | export function clearCache(): void { 195 | cache.clear(); 196 | } 197 | 198 | /** 199 | * Get cache statistics 200 | */ 201 | export function getCacheStats() { 202 | return { 203 | size: cache.size, 204 | keys: Array.from(cache.keys()), 205 | totalMemory: JSON.stringify(Array.from(cache.values())).length 206 | }; 207 | } 208 | 209 | /** 210 | * Clean up corrupted localStorage entries 211 | */ 212 | export function cleanupCorruptedEntries(): number { 213 | let cleanedCount = 0; 214 | 215 | try { 216 | const keys = getAllKeys(); 217 | 218 | for (const key of keys) { 219 | try { 220 | const item = window.localStorage.getItem(key); 221 | if (item && isCorruptedValue(item)) { 222 | console.warn(`Cleaning up corrupted localStorage entry: ${key}`); 223 | window.localStorage.removeItem(key); 224 | cache.delete(key); 225 | cleanedCount++; 226 | } 227 | } catch (error) { 228 | console.error(`Error checking localStorage key "${key}" for corruption:`, error); 229 | } 230 | } 231 | 232 | if (cleanedCount > 0) { 233 | console.log(`Cleaned up ${cleanedCount} corrupted localStorage entries`); 234 | } 235 | } catch (error) { 236 | console.error('Error during localStorage cleanup:', error); 237 | } 238 | 239 | return cleanedCount; 240 | } -------------------------------------------------------------------------------- /src/core/services/version/versionControl.ts: -------------------------------------------------------------------------------- 1 | import { PRESERVED_STORAGE_KEYS, APP_VERSION, STORAGE_KEYS } from '../../config/constants'; 2 | import * as localStorage from '../storage/localStorage'; 3 | 4 | /** 5 | * Get the stored application version 6 | */ 7 | export function getStoredVersion(): string | null { 8 | return localStorage.getItem(STORAGE_KEYS.VERSION, null); 9 | } 10 | 11 | /** 12 | * Set the current application version in storage 13 | */ 14 | export function setStoredVersion(version: string): void { 15 | localStorage.setItem(STORAGE_KEYS.VERSION, version); 16 | } 17 | 18 | /** 19 | * Check if the current version differs from the stored version 20 | */ 21 | export function isVersionChanged(): boolean { 22 | const storedVersion = getStoredVersion(); 23 | return storedVersion !== null && storedVersion !== APP_VERSION; 24 | } 25 | 26 | /** 27 | * Check if this is a fresh installation (no stored version) 28 | */ 29 | export function isFreshInstallation(): boolean { 30 | return getStoredVersion() === null; 31 | } 32 | 33 | /** 34 | * Clear all localStorage data except preserved keys (optimized for performance) 35 | */ 36 | export function clearStorageExceptPreserved(): void { 37 | const allKeys = localStorage.getAllKeys(); 38 | 39 | // Use requestIdleCallback to avoid blocking the main thread 40 | requestIdleCallback(() => { 41 | // Remove all keys except preserved ones 42 | allKeys.forEach(key => { 43 | if (!PRESERVED_STORAGE_KEYS.includes(key)) { 44 | localStorage.removeItem(key); 45 | } 46 | }); 47 | 48 | // Clear localStorage cache to free memory 49 | localStorage.clearCache(); 50 | 51 | console.log(`Cleared ${allKeys.length - PRESERVED_STORAGE_KEYS.length} localStorage items due to version change`); 52 | }); 53 | } 54 | 55 | /** 56 | * Handle version migration with performance optimizations 57 | * Returns true if migration was performed, false otherwise 58 | */ 59 | export function handleVersionMigration(): boolean { 60 | const storedVersion = getStoredVersion(); 61 | 62 | // Fresh installation - just set the current version 63 | if (isFreshInstallation()) { 64 | setStoredVersion(APP_VERSION); 65 | console.log(`Fresh installation detected. Set version to ${APP_VERSION}`); 66 | return false; 67 | } 68 | 69 | // Version changed - perform migration 70 | if (isVersionChanged()) { 71 | console.log(`Version change detected: ${storedVersion} → ${APP_VERSION}`); 72 | 73 | // Clear storage except preserved keys (async to avoid blocking) 74 | clearStorageExceptPreserved(); 75 | 76 | // Update stored version 77 | setStoredVersion(APP_VERSION); 78 | 79 | // Show migration notification (async) 80 | requestIdleCallback(() => { 81 | showMigrationNotification(storedVersion!, APP_VERSION); 82 | }); 83 | 84 | return true; 85 | } 86 | 87 | // No version change 88 | return false; 89 | } 90 | 91 | /** 92 | * Show a notification about version migration (optimized) 93 | */ 94 | function showMigrationNotification(oldVersion: string, newVersion: string): void { 95 | try { 96 | // Try to get translations 97 | let title = 'Studorama Updated!'; 98 | let versionText = `Updated from v${oldVersion} to v${newVersion}`; 99 | let dataText = 'Your data has been refreshed for the new version. Your API key has been preserved.'; 100 | 101 | // Try to detect language from localStorage 102 | try { 103 | const languageSettings = localStorage.getItem<{ language: string }>(STORAGE_KEYS.LANGUAGE, { language: 'en-US' }); 104 | if (languageSettings.language === 'pt-BR') { 105 | title = 'Studorama Atualizado!'; 106 | versionText = `Atualizado da v${oldVersion} para v${newVersion}`; 107 | dataText = 'Seus dados foram atualizados para a nova versão. Sua chave da API foi preservada.'; 108 | } 109 | } catch (e) { 110 | // Ignore language detection errors 111 | } 112 | 113 | // Create notification element 114 | const notification = document.createElement('div'); 115 | notification.style.cssText = ` 116 | position: fixed; 117 | top: 20px; 118 | right: 20px; 119 | background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); 120 | color: white; 121 | padding: 20px 24px; 122 | border-radius: 12px; 123 | box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2); 124 | z-index: 10000; 125 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; 126 | font-size: 14px; 127 | font-weight: 500; 128 | max-width: 400px; 129 | animation: slideInFromRight 0.4s ease-out; 130 | border: 1px solid rgba(255, 255, 255, 0.2); 131 | `; 132 | 133 | // Add animation styles if not already present 134 | if (!document.querySelector('#migration-notification-styles')) { 135 | const style = document.createElement('style'); 136 | style.id = 'migration-notification-styles'; 137 | style.textContent = ` 138 | @keyframes slideInFromRight { 139 | from { 140 | transform: translateX(100%); 141 | opacity: 0; 142 | } 143 | to { 144 | transform: translateX(0); 145 | opacity: 1; 146 | } 147 | } 148 | @keyframes slideOutToRight { 149 | from { 150 | transform: translateX(0); 151 | opacity: 1; 152 | } 153 | to { 154 | transform: translateX(100%); 155 | opacity: 0; 156 | } 157 | } 158 | @keyframes pulse { 159 | 0%, 100% { transform: scale(1); } 160 | 50% { transform: scale(1.05); } 161 | } 162 | `; 163 | document.head.appendChild(style); 164 | } 165 | 166 | notification.innerHTML = ` 167 |
168 |
179 | 🚀 180 |
181 |
182 |
183 | ${title} 184 |
185 |
186 | ${versionText} 187 |
188 |
189 | ${dataText} 190 |
191 |
192 |
193 | `; 194 | 195 | document.body.appendChild(notification); 196 | 197 | // Auto-remove notification after 8 seconds 198 | setTimeout(() => { 199 | notification.style.animation = 'slideOutToRight 0.4s ease-in forwards'; 200 | setTimeout(() => { 201 | if (notification.parentNode) { 202 | notification.parentNode.removeChild(notification); 203 | } 204 | }, 400); 205 | }, 8000); 206 | 207 | // Allow manual dismissal by clicking 208 | notification.addEventListener('click', () => { 209 | notification.style.animation = 'slideOutToRight 0.4s ease-in forwards'; 210 | setTimeout(() => { 211 | if (notification.parentNode) { 212 | notification.parentNode.removeChild(notification); 213 | } 214 | }, 400); 215 | }); 216 | 217 | } catch (error) { 218 | console.error('Error showing migration notification:', error); 219 | } 220 | } 221 | 222 | /** 223 | * Get version information for debugging 224 | */ 225 | export function getVersionInfo() { 226 | return { 227 | currentVersion: APP_VERSION, 228 | storedVersion: getStoredVersion(), 229 | isVersionChanged: isVersionChanged(), 230 | isFreshInstallation: isFreshInstallation(), 231 | preservedKeys: PRESERVED_STORAGE_KEYS, 232 | }; 233 | } 234 | 235 | /** 236 | * Force a version migration (useful for testing) 237 | */ 238 | export function forceMigration(): void { 239 | console.log('Forcing version migration...'); 240 | clearStorageExceptPreserved(); 241 | setStoredVersion(APP_VERSION); 242 | showMigrationNotification('0.0.0', APP_VERSION); 243 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: { 6 | // Enhanced responsive breakpoints 7 | screens: { 8 | 'xs': '375px', 9 | 'sm': '640px', 10 | 'md': '768px', 11 | 'lg': '1024px', 12 | 'xl': '1280px', 13 | '2xl': '1536px', 14 | // Custom breakpoints for specific use cases 15 | 'mobile': {'max': '639px'}, 16 | 'tablet': {'min': '640px', 'max': '1023px'}, 17 | 'desktop': {'min': '1024px'}, 18 | // Landscape orientation 19 | 'landscape': {'raw': '(orientation: landscape)'}, 20 | // High DPI displays 21 | 'retina': {'raw': '(-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)'}, 22 | }, 23 | 24 | // Enhanced spacing scale 25 | spacing: { 26 | '18': '4.5rem', 27 | '88': '22rem', 28 | '128': '32rem', 29 | '144': '36rem', 30 | }, 31 | 32 | // Enhanced font sizes with responsive scaling 33 | fontSize: { 34 | 'xs': ['0.75rem', { lineHeight: '1rem' }], 35 | 'sm': ['0.875rem', { lineHeight: '1.25rem' }], 36 | 'base': ['1rem', { lineHeight: '1.5rem' }], 37 | 'lg': ['1.125rem', { lineHeight: '1.75rem' }], 38 | 'xl': ['1.25rem', { lineHeight: '1.75rem' }], 39 | '2xl': ['1.5rem', { lineHeight: '2rem' }], 40 | '3xl': ['1.875rem', { lineHeight: '2.25rem' }], 41 | '4xl': ['2.25rem', { lineHeight: '2.5rem' }], 42 | '5xl': ['3rem', { lineHeight: '1' }], 43 | '6xl': ['3.75rem', { lineHeight: '1' }], 44 | '7xl': ['4.5rem', { lineHeight: '1' }], 45 | '8xl': ['6rem', { lineHeight: '1' }], 46 | '9xl': ['8rem', { lineHeight: '1' }], 47 | }, 48 | 49 | // Enhanced color palette 50 | colors: { 51 | orange: { 52 | 50: '#fff7ed', 53 | 100: '#ffedd5', 54 | 200: '#fed7aa', 55 | 300: '#fdba74', 56 | 400: '#fb923c', 57 | 500: '#f97316', 58 | 600: '#ea580c', 59 | 700: '#c2410c', 60 | 800: '#9a3412', 61 | 900: '#7c2d12', 62 | 950: '#431407', 63 | }, 64 | gray: { 65 | 50: '#f9fafb', 66 | 100: '#f3f4f6', 67 | 200: '#e5e7eb', 68 | 300: '#d1d5db', 69 | 400: '#9ca3af', 70 | 500: '#6b7280', 71 | 600: '#4b5563', 72 | 700: '#374151', 73 | 800: '#1f2937', 74 | 900: '#111827', 75 | 950: '#030712', 76 | }, 77 | }, 78 | 79 | // Enhanced animations 80 | animation: { 81 | 'fade-in': 'fadeIn 0.3s ease-in-out', 82 | 'slide-up': 'slideUp 0.3s ease-out', 83 | 'slide-down': 'slideDown 0.3s ease-out', 84 | 'scale-in': 'scaleIn 0.2s ease-out', 85 | 'bounce-gentle': 'bounceGentle 2s infinite', 86 | 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', 87 | }, 88 | 89 | keyframes: { 90 | fadeIn: { 91 | '0%': { opacity: '0' }, 92 | '100%': { opacity: '1' }, 93 | }, 94 | slideUp: { 95 | '0%': { opacity: '0', transform: 'translateY(20px)' }, 96 | '100%': { opacity: '1', transform: 'translateY(0)' }, 97 | }, 98 | slideDown: { 99 | '0%': { opacity: '0', transform: 'translateY(-20px)' }, 100 | '100%': { opacity: '1', transform: 'translateY(0)' }, 101 | }, 102 | scaleIn: { 103 | '0%': { opacity: '0', transform: 'scale(0.95)' }, 104 | '100%': { opacity: '1', transform: 'scale(1)' }, 105 | }, 106 | bounceGentle: { 107 | '0%, 100%': { transform: 'translateY(0)' }, 108 | '50%': { transform: 'translateY(-5px)' }, 109 | }, 110 | }, 111 | 112 | // Enhanced shadows 113 | boxShadow: { 114 | 'xs': '0 1px 2px 0 rgb(0 0 0 / 0.05)', 115 | 'sm': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)', 116 | 'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', 117 | 'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', 118 | 'xl': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)', 119 | '2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)', 120 | 'inner': 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)', 121 | 'glow': '0 0 20px rgb(234 88 12 / 0.3)', 122 | 'glow-lg': '0 0 40px rgb(234 88 12 / 0.4)', 123 | }, 124 | 125 | // Enhanced border radius 126 | borderRadius: { 127 | 'none': '0', 128 | 'sm': '0.125rem', 129 | 'DEFAULT': '0.25rem', 130 | 'md': '0.375rem', 131 | 'lg': '0.5rem', 132 | 'xl': '0.75rem', 133 | '2xl': '1rem', 134 | '3xl': '1.5rem', 135 | 'full': '9999px', 136 | }, 137 | 138 | // Enhanced backdrop blur 139 | backdropBlur: { 140 | 'xs': '2px', 141 | 'sm': '4px', 142 | 'md': '8px', 143 | 'lg': '12px', 144 | 'xl': '16px', 145 | '2xl': '24px', 146 | '3xl': '40px', 147 | }, 148 | 149 | // Enhanced z-index scale 150 | zIndex: { 151 | '0': '0', 152 | '10': '10', 153 | '20': '20', 154 | '30': '30', 155 | '40': '40', 156 | '50': '50', 157 | 'auto': 'auto', 158 | }, 159 | 160 | // Enhanced aspect ratios 161 | aspectRatio: { 162 | 'auto': 'auto', 163 | 'square': '1 / 1', 164 | 'video': '16 / 9', 165 | 'photo': '4 / 3', 166 | 'golden': '1.618 / 1', 167 | }, 168 | 169 | // Enhanced line heights 170 | lineHeight: { 171 | '3': '.75rem', 172 | '4': '1rem', 173 | '5': '1.25rem', 174 | '6': '1.5rem', 175 | '7': '1.75rem', 176 | '8': '2rem', 177 | '9': '2.25rem', 178 | '10': '2.5rem', 179 | }, 180 | 181 | // Enhanced letter spacing 182 | letterSpacing: { 183 | 'tighter': '-0.05em', 184 | 'tight': '-0.025em', 185 | 'normal': '0em', 186 | 'wide': '0.025em', 187 | 'wider': '0.05em', 188 | 'widest': '0.1em', 189 | }, 190 | 191 | // Enhanced max widths 192 | maxWidth: { 193 | 'none': 'none', 194 | 'xs': '20rem', 195 | 'sm': '24rem', 196 | 'md': '28rem', 197 | 'lg': '32rem', 198 | 'xl': '36rem', 199 | '2xl': '42rem', 200 | '3xl': '48rem', 201 | '4xl': '56rem', 202 | '5xl': '64rem', 203 | '6xl': '72rem', 204 | '7xl': '80rem', 205 | 'full': '100%', 206 | 'min': 'min-content', 207 | 'max': 'max-content', 208 | 'fit': 'fit-content', 209 | 'prose': '65ch', 210 | }, 211 | 212 | // Enhanced transitions 213 | transitionDuration: { 214 | '75': '75ms', 215 | '100': '100ms', 216 | '150': '150ms', 217 | '200': '200ms', 218 | '300': '300ms', 219 | '500': '500ms', 220 | '700': '700ms', 221 | '1000': '1000ms', 222 | }, 223 | 224 | transitionTimingFunction: { 225 | 'in-expo': 'cubic-bezier(0.95, 0.05, 0.795, 0.035)', 226 | 'out-expo': 'cubic-bezier(0.19, 1, 0.22, 1)', 227 | 'in-out-expo': 'cubic-bezier(1, 0, 0, 1)', 228 | 'in-circ': 'cubic-bezier(0.6, 0.04, 0.98, 0.335)', 229 | 'out-circ': 'cubic-bezier(0.075, 0.82, 0.165, 1)', 230 | 'in-out-circ': 'cubic-bezier(0.785, 0.135, 0.15, 0.86)', 231 | }, 232 | }, 233 | }, 234 | plugins: [ 235 | // Add any additional plugins here 236 | function({ addUtilities, addComponents, theme }) { 237 | // Custom utilities for responsive design 238 | addUtilities({ 239 | '.safe-top': { 240 | 'padding-top': 'env(safe-area-inset-top)', 241 | }, 242 | '.safe-bottom': { 243 | 'padding-bottom': 'env(safe-area-inset-bottom)', 244 | }, 245 | '.safe-left': { 246 | 'padding-left': 'env(safe-area-inset-left)', 247 | }, 248 | '.safe-right': { 249 | 'padding-right': 'env(safe-area-inset-right)', 250 | }, 251 | '.touch-manipulation': { 252 | 'touch-action': 'manipulation', 253 | }, 254 | '.scrollbar-hide': { 255 | '-ms-overflow-style': 'none', 256 | 'scrollbar-width': 'none', 257 | '&::-webkit-scrollbar': { 258 | display: 'none', 259 | }, 260 | }, 261 | '.text-balance': { 262 | 'text-wrap': 'balance', 263 | }, 264 | '.text-pretty': { 265 | 'text-wrap': 'pretty', 266 | }, 267 | }); 268 | 269 | // Custom components for common patterns 270 | addComponents({ 271 | '.container-responsive': { 272 | width: '100%', 273 | maxWidth: theme('maxWidth.7xl'), 274 | marginLeft: 'auto', 275 | marginRight: 'auto', 276 | paddingLeft: theme('spacing.3'), 277 | paddingRight: theme('spacing.3'), 278 | '@screen sm': { 279 | paddingLeft: theme('spacing.4'), 280 | paddingRight: theme('spacing.4'), 281 | }, 282 | '@screen md': { 283 | paddingLeft: theme('spacing.6'), 284 | paddingRight: theme('spacing.6'), 285 | }, 286 | '@screen lg': { 287 | paddingLeft: theme('spacing.8'), 288 | paddingRight: theme('spacing.8'), 289 | }, 290 | }, 291 | '.focus-ring': { 292 | '&:focus': { 293 | outline: 'none', 294 | 'box-shadow': `0 0 0 2px ${theme('colors.orange.500')}`, 295 | }, 296 | }, 297 | '.touch-target': { 298 | minHeight: '44px', 299 | minWidth: '44px', 300 | }, 301 | }); 302 | }, 303 | ], 304 | }; -------------------------------------------------------------------------------- /src/components/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, useLocation } from 'react-router-dom'; 3 | import { Home, History, Settings, BookOpen, Menu, X, Heart } from 'lucide-react'; 4 | import { useLanguage, useTheme } from '../../hooks'; 5 | import Logo from '../ui/Logo'; 6 | import ThemeSelector from '../ui/ThemeSelector'; 7 | import IconButton from '../ui/IconButton'; 8 | 9 | interface LayoutProps { 10 | children: React.ReactNode; 11 | } 12 | 13 | export default function Layout({ children }: LayoutProps) { 14 | const location = useLocation(); 15 | const { t, language } = useLanguage(); 16 | const { themeConfig } = useTheme(); 17 | const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); 18 | 19 | const navItems = [ 20 | { path: '/', icon: Home, label: t.dashboard }, 21 | { path: '/study', icon: BookOpen, label: t.study }, 22 | { path: '/history', icon: History, label: t.history }, 23 | { path: '/settings', icon: Settings, label: t.settings }, 24 | ]; 25 | 26 | const supportItems = [ 27 | { path: '/pricing', icon: Heart, label: language === 'pt-BR' ? 'Apoie-nos' : 'Support Us' }, 28 | ]; 29 | 30 | const isActive = (path: string) => { 31 | if (path === '/') { 32 | return location.pathname === '/'; 33 | } 34 | return location.pathname.startsWith(path); 35 | }; 36 | 37 | // Close mobile menu when route changes 38 | React.useEffect(() => { 39 | setMobileMenuOpen(false); 40 | }, [location.pathname]); 41 | 42 | return ( 43 |
47 | {/* Skip to main content link for accessibility */} 48 | e.currentTarget.classList.add('not-sr-only')} 52 | onBlur={(e) => e.currentTarget.classList.remove('not-sr-only')} 53 | > 54 | Skip to main content 55 | 56 | 57 | 239 | 240 |
245 | {children} 246 |
247 |
248 | ); 249 | } -------------------------------------------------------------------------------- /src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, useLocation } from 'react-router-dom'; 3 | import { Home, History, Settings, BookOpen, Menu, X, Heart } from 'lucide-react'; 4 | import { useLanguage } from '../hooks/useLanguage'; 5 | import { useTheme } from '../hooks/useTheme'; 6 | import Logo from './Logo'; 7 | import ThemeSelector from './ThemeSelector'; 8 | import IconButton from './ui/IconButton'; 9 | 10 | interface LayoutProps { 11 | children: React.ReactNode; 12 | } 13 | 14 | export default function Layout({ children }: LayoutProps) { 15 | const location = useLocation(); 16 | const { t, language } = useLanguage(); 17 | const { themeConfig } = useTheme(); 18 | const [mobileMenuOpen, setMobileMenuOpen] = React.useState(false); 19 | 20 | const navItems = [ 21 | { path: '/', icon: Home, label: t.dashboard }, 22 | { path: '/study', icon: BookOpen, label: t.study }, 23 | { path: '/history', icon: History, label: t.history }, 24 | { path: '/settings', icon: Settings, label: t.settings }, 25 | ]; 26 | 27 | const supportItems = [ 28 | { path: '/pricing', icon: Heart, label: language === 'pt-BR' ? 'Apoie-nos' : 'Support Us' }, 29 | ]; 30 | 31 | const isActive = (path: string) => { 32 | if (path === '/') { 33 | return location.pathname === '/'; 34 | } 35 | return location.pathname.startsWith(path); 36 | }; 37 | 38 | // Close mobile menu when route changes 39 | React.useEffect(() => { 40 | setMobileMenuOpen(false); 41 | }, [location.pathname]); 42 | 43 | return ( 44 |
48 | {/* Skip to main content link for accessibility */} 49 | e.currentTarget.classList.add('not-sr-only')} 53 | onBlur={(e) => e.currentTarget.classList.remove('not-sr-only')} 54 | > 55 | Skip to main content 56 | 57 | 58 | 240 | 241 |
246 | {children} 247 |
248 |
249 | ); 250 | } -------------------------------------------------------------------------------- /scripts/version-manager.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Version Manager for Studorama 5 | * Automatically increments version numbers and updates all relevant files 6 | */ 7 | 8 | import fs from 'fs'; 9 | import path from 'path'; 10 | import { fileURLToPath } from 'url'; 11 | 12 | const __filename = fileURLToPath(import.meta.url); 13 | const __dirname = path.dirname(__filename); 14 | const projectRoot = path.resolve(__dirname, '..'); 15 | 16 | // Version types 17 | const VERSION_TYPES = { 18 | MAJOR: 'major', // Breaking changes (1.0.0 -> 2.0.0) 19 | MINOR: 'minor', // New features (1.0.0 -> 1.1.0) 20 | PATCH: 'patch', // Bug fixes (1.0.0 -> 1.0.1) 21 | BUILD: 'build' // Build number (1.0.0 -> 1.0.1) 22 | }; 23 | 24 | class VersionManager { 25 | constructor() { 26 | this.packageJsonPath = path.join(projectRoot, 'package.json'); 27 | this.envPath = path.join(projectRoot, '.env'); 28 | this.indexHtmlPath = path.join(projectRoot, 'index.html'); 29 | this.swPath = path.join(projectRoot, 'public/sw.js'); 30 | this.constantsPath = path.join(projectRoot, 'src/core/config/constants.ts'); 31 | } 32 | 33 | /** 34 | * Parse version string into components 35 | */ 36 | parseVersion(versionString) { 37 | const match = versionString.match(/^(\d+)\.(\d+)\.(\d+)$/); 38 | if (!match) { 39 | throw new Error(`Invalid version format: ${versionString}`); 40 | } 41 | 42 | return { 43 | major: parseInt(match[1], 10), 44 | minor: parseInt(match[2], 10), 45 | patch: parseInt(match[3], 10) 46 | }; 47 | } 48 | 49 | /** 50 | * Increment version based on type 51 | */ 52 | incrementVersion(currentVersion, type) { 53 | const version = this.parseVersion(currentVersion); 54 | 55 | switch (type) { 56 | case VERSION_TYPES.MAJOR: 57 | version.major += 1; 58 | version.minor = 0; 59 | version.patch = 0; 60 | break; 61 | case VERSION_TYPES.MINOR: 62 | version.minor += 1; 63 | version.patch = 0; 64 | break; 65 | case VERSION_TYPES.PATCH: 66 | case VERSION_TYPES.BUILD: 67 | default: 68 | version.patch += 1; 69 | break; 70 | } 71 | 72 | return `${version.major}.${version.minor}.${version.patch}`; 73 | } 74 | 75 | /** 76 | * Get current version from package.json 77 | */ 78 | getCurrentVersion() { 79 | try { 80 | const packageJson = JSON.parse(fs.readFileSync(this.packageJsonPath, 'utf8')); 81 | return packageJson.version || '1.0.0'; 82 | } catch (error) { 83 | console.warn('Could not read package.json, defaulting to 1.0.0'); 84 | return '1.0.0'; 85 | } 86 | } 87 | 88 | /** 89 | * Update package.json version 90 | */ 91 | updatePackageJson(newVersion) { 92 | try { 93 | const packageJson = JSON.parse(fs.readFileSync(this.packageJsonPath, 'utf8')); 94 | packageJson.version = newVersion; 95 | fs.writeFileSync(this.packageJsonPath, JSON.stringify(packageJson, null, 2) + '\n'); 96 | console.log(`✓ Updated package.json to version ${newVersion}`); 97 | } catch (error) { 98 | console.error('Failed to update package.json:', error.message); 99 | } 100 | } 101 | 102 | /** 103 | * Update .env file 104 | */ 105 | updateEnvFile(newVersion) { 106 | try { 107 | let envContent = ''; 108 | 109 | if (fs.existsSync(this.envPath)) { 110 | envContent = fs.readFileSync(this.envPath, 'utf8'); 111 | } 112 | 113 | // Update or add APP_VERSION 114 | if (envContent.includes('APP_VERSION=')) { 115 | envContent = envContent.replace(/APP_VERSION=.*$/m, `APP_VERSION=${newVersion}`); 116 | } else { 117 | envContent += `\nAPP_VERSION=${newVersion}\n`; 118 | } 119 | 120 | fs.writeFileSync(this.envPath, envContent); 121 | console.log(`✓ Updated .env with APP_VERSION=${newVersion}`); 122 | } catch (error) { 123 | console.error('Failed to update .env:', error.message); 124 | } 125 | } 126 | 127 | /** 128 | * Update index.html structured data 129 | */ 130 | updateIndexHtml(newVersion) { 131 | try { 132 | let content = fs.readFileSync(this.indexHtmlPath, 'utf8'); 133 | 134 | // Update softwareVersion in structured data 135 | content = content.replace( 136 | /"softwareVersion": "[^"]*"/g, 137 | `"softwareVersion": "${newVersion}"` 138 | ); 139 | 140 | fs.writeFileSync(this.indexHtmlPath, content); 141 | console.log(`✓ Updated index.html structured data to version ${newVersion}`); 142 | } catch (error) { 143 | console.error('Failed to update index.html:', error.message); 144 | } 145 | } 146 | 147 | /** 148 | * Update service worker cache names 149 | */ 150 | updateServiceWorker(newVersion) { 151 | try { 152 | let content = fs.readFileSync(this.swPath, 'utf8'); 153 | 154 | // Update cache name 155 | content = content.replace( 156 | /const CACHE_NAME = 'studorama-v[^']*'/, 157 | `const CACHE_NAME = 'studorama-v${newVersion}'` 158 | ); 159 | 160 | fs.writeFileSync(this.swPath, content); 161 | console.log(`✓ Updated service worker cache name to v${newVersion}`); 162 | } catch (error) { 163 | console.error('Failed to update service worker:', error.message); 164 | } 165 | } 166 | 167 | /** 168 | * Update constants file with static version strings 169 | */ 170 | updateConstants(newVersion) { 171 | try { 172 | let content = fs.readFileSync(this.constantsPath, 'utf8'); 173 | 174 | // Update APP_VERSION constant 175 | content = content.replace( 176 | /export const APP_VERSION = '[^']*'/, 177 | `export const APP_VERSION = '${newVersion}'` 178 | ); 179 | 180 | // Update cache names in CACHE_NAMES object 181 | content = content.replace( 182 | /STATIC: `studorama-static-v[^`]*`/, 183 | `STATIC: \`studorama-static-v${newVersion}\`` 184 | ); 185 | content = content.replace( 186 | /DYNAMIC: `studorama-dynamic-v[^`]*`/, 187 | `DYNAMIC: \`studorama-dynamic-v${newVersion}\`` 188 | ); 189 | content = content.replace( 190 | /MAIN: `studorama-v[^`]*`/, 191 | `MAIN: \`studorama-v${newVersion}\`` 192 | ); 193 | 194 | fs.writeFileSync(this.constantsPath, content); 195 | console.log(`✓ Updated constants.ts with static version ${newVersion}`); 196 | } catch (error) { 197 | console.error('Failed to update constants.ts:', error.message); 198 | } 199 | } 200 | 201 | /** 202 | * Create a version info file 203 | */ 204 | createVersionInfo(newVersion) { 205 | const versionInfo = { 206 | version: newVersion, 207 | buildDate: new Date().toISOString(), 208 | buildNumber: Date.now(), 209 | environment: process.env.NODE_ENV || 'development' 210 | }; 211 | 212 | const versionInfoPath = path.join(projectRoot, 'public/version.json'); 213 | fs.writeFileSync(versionInfoPath, JSON.stringify(versionInfo, null, 2)); 214 | console.log(`✓ Created version info file: ${versionInfo.version} (${versionInfo.buildDate})`); 215 | } 216 | 217 | /** 218 | * Main version bump function 219 | */ 220 | bump(type = VERSION_TYPES.PATCH) { 221 | console.log(`🚀 Starting version bump (${type})...`); 222 | 223 | const currentVersion = this.getCurrentVersion(); 224 | const newVersion = this.incrementVersion(currentVersion, type); 225 | 226 | console.log(`📦 Current version: ${currentVersion}`); 227 | console.log(`📦 New version: ${newVersion}`); 228 | 229 | // Update all files 230 | this.updatePackageJson(newVersion); 231 | this.updateEnvFile(newVersion); 232 | this.updateIndexHtml(newVersion); 233 | this.updateServiceWorker(newVersion); 234 | this.updateConstants(newVersion); 235 | this.createVersionInfo(newVersion); 236 | 237 | console.log(`✅ Version bump complete! New version: ${newVersion}`); 238 | return newVersion; 239 | } 240 | 241 | /** 242 | * Get version information 243 | */ 244 | info() { 245 | const currentVersion = this.getCurrentVersion(); 246 | const parsed = this.parseVersion(currentVersion); 247 | 248 | console.log(`📦 Current version: ${currentVersion}`); 249 | console.log(` Major: ${parsed.major}`); 250 | console.log(` Minor: ${parsed.minor}`); 251 | console.log(` Patch: ${parsed.patch}`); 252 | 253 | return currentVersion; 254 | } 255 | } 256 | 257 | // CLI Interface 258 | function main() { 259 | const args = process.argv.slice(2); 260 | const command = args[0]; 261 | const type = args[1] || VERSION_TYPES.PATCH; 262 | 263 | const versionManager = new VersionManager(); 264 | 265 | switch (command) { 266 | case 'bump': 267 | if (!Object.values(VERSION_TYPES).includes(type)) { 268 | console.error(`❌ Invalid version type: ${type}`); 269 | console.log(`Valid types: ${Object.values(VERSION_TYPES).join(', ')}`); 270 | process.exit(1); 271 | } 272 | versionManager.bump(type); 273 | break; 274 | 275 | case 'info': 276 | versionManager.info(); 277 | break; 278 | 279 | case 'major': 280 | versionManager.bump(VERSION_TYPES.MAJOR); 281 | break; 282 | 283 | case 'minor': 284 | versionManager.bump(VERSION_TYPES.MINOR); 285 | break; 286 | 287 | case 'patch': 288 | versionManager.bump(VERSION_TYPES.PATCH); 289 | break; 290 | 291 | default: 292 | console.log(` 293 | 📦 Studorama Version Manager 294 | 295 | Usage: 296 | node scripts/version-manager.js [type] 297 | 298 | Commands: 299 | bump [type] Bump version by type (patch, minor, major) 300 | major Bump major version (breaking changes) 301 | minor Bump minor version (new features) 302 | patch Bump patch version (bug fixes) 303 | info Show current version info 304 | 305 | Examples: 306 | node scripts/version-manager.js bump patch 307 | node scripts/version-manager.js major 308 | node scripts/version-manager.js info 309 | `); 310 | break; 311 | } 312 | } 313 | 314 | // Run if called directly 315 | if (import.meta.url === `file://${process.argv[1]}`) { 316 | main(); 317 | } 318 | 319 | export default VersionManager; -------------------------------------------------------------------------------- /src/components/ui/ThemeSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Check, Focus, Palette, Sun, Waves, X, Zap } from 'lucide-react'; 2 | import React, { useState } from 'react'; 3 | import { getLocalizedCategoryName, getLocalizedThemeDescription, getLocalizedThemeName } from '../../core/services/theme/themeRegistry'; 4 | import { ThemeCategory, ThemeConfig } from '../../core/types'; 5 | import { useLanguage } from '../../hooks/useLanguage'; 6 | import { useTheme } from '../../hooks/useTheme'; 7 | import IconButton from './IconButton'; 8 | 9 | interface ThemeSelectorProps { 10 | className?: string; 11 | showLabel?: boolean; 12 | } 13 | 14 | const categoryIcons: Record> = { 15 | standard: Sun, 16 | focus: Focus, 17 | energy: Zap, 18 | calm: Waves, 19 | }; 20 | 21 | export default function ThemeSelector({ className = '', showLabel = true }: ThemeSelectorProps) { 22 | const { currentTheme, changeTheme, getAllThemes, getThemesByCategory, themeConfig } = useTheme(); 23 | const { language, t } = useLanguage(); 24 | const [isOpen, setIsOpen] = useState(false); 25 | const [selectedCategory, setSelectedCategory] = useState('all'); 26 | 27 | const allThemes = getAllThemes(); 28 | const categories: ThemeCategory[] = ['standard', 'focus', 'energy', 'calm']; 29 | 30 | const filteredThemes = selectedCategory === 'all' 31 | ? allThemes 32 | : getThemesByCategory(selectedCategory); 33 | 34 | const handleThemeChange = (theme: string) => { 35 | changeTheme(theme as any); 36 | setIsOpen(false); 37 | }; 38 | 39 | const getThemePreview = (theme: ThemeConfig) => { 40 | return ( 41 |
42 |
46 |
50 |
54 |
55 | ); 56 | }; 57 | 58 | return ( 59 |
60 | {/* Theme Selector Button */} 61 | 88 | 89 | {/* Theme Selector Modal */} 90 | {isOpen && ( 91 | <> 92 | {/* Backdrop */} 93 |
setIsOpen(false)} 96 | /> 97 | 98 | {/* Modal - Fixed positioning to prevent overflow */} 99 |
106 | {/* Header */} 107 |
111 |
112 |

113 | {t.selectTheme} 114 |

115 | setIsOpen(false)} 118 | variant="ghost" 119 | size="sm" 120 | aria-label={language === 'pt-BR' ? 'Fechar seletor de tema' : 'Close theme selector'} 121 | /> 122 |
123 | 124 | {/* Category Filter */} 125 |
126 | 140 | {categories.map((category) => { 141 | const Icon = categoryIcons[category]; 142 | return ( 143 | 155 | ); 156 | })} 157 |
158 |
159 | 160 | {/* Theme Grid - Scrollable */} 161 |
162 |
163 | {filteredThemes.map((theme) => ( 164 | 219 | ))} 220 |
221 |
222 | 223 | {/* Footer */} 224 |
231 |

232 | {language === 'pt-BR' 233 | ? 'Temas otimizados para diferentes estilos de aprendizado' 234 | : 'Themes optimized for different learning styles' 235 | } 236 |

237 |
238 |
239 | 240 | )} 241 |
242 | ); 243 | } -------------------------------------------------------------------------------- /src/components/ThemeSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Check, Focus, Palette, Sun, Waves, X, Zap } from 'lucide-react'; 2 | import React, { useState } from 'react'; 3 | import { useLanguage } from '../hooks/useLanguage'; 4 | import { Theme, ThemeConfig, useTheme } from '../hooks/useTheme'; 5 | import IconButton from './ui/IconButton'; 6 | 7 | interface ThemeSelectorProps { 8 | className?: string; 9 | showLabel?: boolean; 10 | } 11 | 12 | const categoryIcons = { 13 | standard: Sun, 14 | focus: Focus, 15 | energy: Zap, 16 | calm: Waves, 17 | }; 18 | 19 | export default function ThemeSelector({ className = '', showLabel = true }: ThemeSelectorProps) { 20 | const { currentTheme, changeTheme, getAllThemes, getThemesByCategory, themeConfig } = useTheme(); 21 | const { language, t } = useLanguage(); 22 | const [isOpen, setIsOpen] = useState(false); 23 | const [selectedCategory, setSelectedCategory] = useState('all'); 24 | 25 | const allThemes = getAllThemes(); 26 | const categories: ThemeConfig['category'][] = ['standard', 'focus', 'energy', 'calm']; 27 | 28 | const filteredThemes = selectedCategory === 'all' 29 | ? allThemes 30 | : getThemesByCategory(selectedCategory); 31 | 32 | const handleThemeChange = (theme: Theme) => { 33 | changeTheme(theme); 34 | setIsOpen(false); 35 | }; 36 | 37 | const getCategoryLabel = (category: ThemeConfig['category']) => { 38 | switch (category) { 39 | case 'standard': 40 | return language === 'pt-BR' ? 'Padrão' : 'Standard'; 41 | case 'focus': 42 | return language === 'pt-BR' ? 'Foco' : 'Focus'; 43 | case 'energy': 44 | return language === 'pt-BR' ? 'Energia' : 'Energy'; 45 | case 'calm': 46 | return language === 'pt-BR' ? 'Calmo' : 'Calm'; 47 | default: 48 | return category; 49 | } 50 | }; 51 | 52 | const getThemePreview = (theme: ThemeConfig) => { 53 | return ( 54 |
55 |
59 |
63 |
67 |
68 | ); 69 | }; 70 | 71 | const getLocalizedThemeName = (theme: ThemeConfig) => { 72 | if (language === 'pt-BR') { 73 | switch (theme.id) { 74 | case 'light': return 'Claro'; 75 | case 'dark': return 'Escuro'; 76 | case 'focus': return 'Modo Foco'; 77 | case 'midnight': return 'Meia-noite'; 78 | case 'forest': return 'Floresta'; 79 | case 'ocean': return 'Oceano'; 80 | case 'sunset': return 'Pôr do Sol'; 81 | case 'neon': return 'Neon'; 82 | case 'minimal': return 'Minimalista'; 83 | case 'warm': return 'Quente'; 84 | default: return theme.name; 85 | } 86 | } 87 | return theme.name; 88 | }; 89 | 90 | const getLocalizedThemeDescription = (theme: ThemeConfig) => { 91 | if (language === 'pt-BR') { 92 | switch (theme.id) { 93 | case 'light': return 'Limpo e brilhante para estudar durante o dia'; 94 | case 'dark': return 'Suave para os olhos para estudar à noite'; 95 | case 'focus': return 'Distrações mínimas para concentração profunda'; 96 | case 'midnight': return 'Tema azul profundo para sessões de estudo noturnas'; 97 | case 'forest': return 'Tema verde natural para aprendizado calmo e focado'; 98 | case 'ocean': return 'Tema azul calmante inspirado no oceano'; 99 | case 'sunset': return 'Tema laranja e rosa quente para sessões de estudo energizantes'; 100 | case 'neon': return 'Tema cyberpunk de alta energia para sessões de estudo intensas'; 101 | case 'minimal': return 'Design ultra-limpo para aprendizado sem distrações'; 102 | case 'warm': return 'Tema aconchegante e confortável para estudar relaxado'; 103 | default: return theme.description; 104 | } 105 | } 106 | return theme.description; 107 | }; 108 | 109 | return ( 110 |
111 | {/* Theme Selector Button */} 112 | 139 | 140 | {/* Theme Selector Modal */} 141 | {isOpen && ( 142 | <> 143 | {/* Backdrop */} 144 |
setIsOpen(false)} 147 | /> 148 | 149 | {/* Modal - Fixed positioning to prevent overflow */} 150 |
157 | {/* Header */} 158 |
162 |
163 |

164 | {t.selectTheme} 165 |

166 | setIsOpen(false)} 169 | variant="ghost" 170 | size="sm" 171 | aria-label={language === 'pt-BR' ? 'Fechar seletor de tema' : 'Close theme selector'} 172 | /> 173 |
174 | 175 | {/* Category Filter */} 176 |
177 | 191 | {categories.map((category) => { 192 | const Icon = categoryIcons[category]; 193 | return ( 194 | 206 | ); 207 | })} 208 |
209 |
210 | 211 | {/* Theme Grid - Scrollable */} 212 |
213 |
214 | {filteredThemes.map((theme) => ( 215 | 270 | ))} 271 |
272 |
273 | 274 | {/* Footer */} 275 |
282 |

283 | {language === 'pt-BR' 284 | ? 'Temas otimizados para diferentes estilos de aprendizado' 285 | : 'Themes optimized for different learning styles' 286 | } 287 |

288 |
289 |
290 | 291 | )} 292 |
293 | ); 294 | } -------------------------------------------------------------------------------- /src/components/SessionDetails.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowLeft, BookOpen, Calendar, CheckCircle, Clock, Target, TrendingUp, XCircle } from 'lucide-react'; 2 | import { Link, useNavigate, useParams } from 'react-router-dom'; 3 | import { useLanguage } from '../hooks/useLanguage'; 4 | import { useLocalStorage } from '../hooks/useLocalStorage'; 5 | import { StudySession } from '../types'; 6 | import MarkdownRenderer from './MarkdownRenderer'; 7 | import { formatDate } from '../core/services'; 8 | 9 | export default function SessionDetails() { 10 | const { id } = useParams<{ id: string }>(); 11 | const navigate = useNavigate(); 12 | const { t, language } = useLanguage(); 13 | const [sessions] = useLocalStorage('studorama-sessions', []); 14 | 15 | const session = sessions.find(s => s.id === id); 16 | 17 | if (!session) { 18 | return ( 19 |
20 |
21 |
22 | 23 |
24 |

{t.sessionNotFound}

25 |

{t.sessionNotFoundDesc}

26 | 30 | 31 | {t.backToHistory} 32 | 33 |
34 |
35 | ); 36 | } 37 | 38 | const correctAnswers = session.questions.filter(q => q.isCorrect).length; 39 | const totalQuestions = session.questions.length; 40 | const accuracy = totalQuestions > 0 ? Math.round((correctAnswers / totalQuestions) * 100) : 0; 41 | 42 | return ( 43 |
44 | {/* Header */} 45 |
46 |
47 | 53 |
54 |

{session.contexts.join(', ')}

55 | {session.instructions && session.instructions.length > 0 && ( 56 |
57 | {session.instructions.map((instruction, index) => ( 58 |

{instruction}

59 | ))} 60 |
61 | )} 62 |

{t.sessionDetails}

63 |
64 |
65 |
70 | {session.status === 'completed' ? t.completed : t.inProgress} 71 |
72 |
73 | 74 | {/* Session Overview */} 75 |
76 |
77 |
78 |
79 |

{t.date}

80 |

81 | {formatDate(session.createdAt, language)} 82 |

83 |
84 | 85 |
86 |
87 | 88 |
89 |
90 |
91 |

{t.questions}

92 |

{totalQuestions}

93 |
94 | 95 |
96 |
97 | 98 |
99 |
100 |
101 |

{t.accuracy}

102 |

{accuracy}%

103 |
104 | 105 |
106 |
107 | 108 |
109 |
110 |
111 |

{t.score}

112 |

{session.score}%

113 |
114 | 115 |
116 |
117 |
118 | 119 | {/* Questions List */} 120 |
121 |
122 |

{t.questionsAndAnswers}

123 |
124 |
125 | {session.questions.map((question, index) => ( 126 |
127 |
128 |
129 |
134 | {index + 1} 135 |
136 |
137 | 138 |
139 |
140 |
141 | 142 |
143 | {question.isCorrect ? ( 144 | 145 | ) : ( 146 | 147 | )} 148 |
149 | 150 | {question.type === 'multipleChoice' && question.options ? ( 151 |
152 | {question.options.map((option, optionIndex) => ( 153 |
163 |
164 |
165 | 166 |
167 |
168 | {optionIndex === question.correctAnswer && ( 169 | 170 | {t.correct} 171 | 172 | )} 173 | {optionIndex === question.userAnswer && optionIndex !== question.correctAnswer && ( 174 | 175 | {t.yourAnswer} 176 | 177 | )} 178 |
179 |
180 |
181 | ))} 182 |
183 | ) : ( 184 |
185 |
186 |

{t.yourAnswer}:

187 |
188 | 189 |
190 |
191 | {question.correctAnswerText && ( 192 |
193 |

{t.modelAnswer}:

194 |
195 | 196 |
197 |
198 | )} 199 |
200 | )} 201 | 202 | {question.feedback && ( 203 |
206 |

209 | {t.feedback} 210 |

211 |
214 | 215 |
216 |
217 | )} 218 | 219 | {question.aiEvaluation && ( 220 |
221 |

{t.aiEvaluation}

222 |
223 | 224 |
225 |
226 | )} 227 | 228 |
229 |
230 | 231 | {question.attempts} {question.attempts !== 1 ? t.attempts : t.attempt} 232 |
233 |
234 | 235 | {question.type === 'multipleChoice' ? t.multipleChoice : t.dissertative} 236 |
237 |
238 |
239 |
240 |
241 | ))} 242 |
243 |
244 | 245 | {/* Continue Session Button */} 246 | {session.status === 'active' && ( 247 |
248 | 253 | 254 | {t.continueSession} 255 | 256 |
257 | )} 258 |
259 | ); 260 | } 261 | -------------------------------------------------------------------------------- /src/hooks/useApiKeyFromUrl.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { DEFAULTS, STORAGE_KEYS } from '../core/config/constants'; 3 | import { getProviderConfig, validateProviderConfig } from '../core/services/ai/providers/registry'; 4 | import { showNotification } from '../core/services/notification'; 5 | import * as localStorage from '../core/services/storage/localStorage'; 6 | import { APISettings } from '../core/types'; 7 | import { AIProvider } from '../core/types/ai.types'; 8 | import { useLanguage } from './useLanguage'; 9 | import { useLocalStorage } from './useLocalStorage'; 10 | 11 | // Multi-provider settings interface 12 | interface MultiProviderSettings { 13 | currentProvider: AIProvider; 14 | providers: Record; 19 | }>; 20 | } 21 | 22 | /** 23 | * Extract API key and provider info from URL immediately (before version control) 24 | * This prevents the API key from being lost during version migrations 25 | */ 26 | function extractApiKeyFromUrl(): { 27 | apiKey: string | null; 28 | model: string | null; 29 | provider: AIProvider | null; 30 | baseUrl: string | null; 31 | } { 32 | if (typeof window === 'undefined') return { apiKey: null, model: null, provider: null, baseUrl: null }; 33 | 34 | try { 35 | const urlParams = new URLSearchParams(window.location.search); 36 | const apiKey = urlParams.get('apikey') || urlParams.get('api_key') || urlParams.get('key'); 37 | const model = urlParams.get('model'); 38 | const provider = urlParams.get('provider') as AIProvider; 39 | const baseUrl = urlParams.get('baseUrl') || urlParams.get('base_url'); 40 | 41 | return { apiKey, model, provider, baseUrl }; 42 | } catch (error) { 43 | console.error('Error extracting API key from URL:', error); 44 | return { apiKey: null, model: null, provider: null, baseUrl: null }; 45 | } 46 | } 47 | 48 | /** 49 | * Clean URL parameters to avoid showing API key in browser history 50 | */ 51 | function cleanUrlParameters(): void { 52 | if (typeof window === "undefined") return; 53 | 54 | try { 55 | const cleanUrl = new URL(window.location.href); 56 | const hadParams = cleanUrl.searchParams.has('apikey') || 57 | cleanUrl.searchParams.has('api_key') || 58 | cleanUrl.searchParams.has('key') || 59 | cleanUrl.searchParams.has('model') || 60 | cleanUrl.searchParams.has('provider') || 61 | cleanUrl.searchParams.has('baseUrl') || 62 | cleanUrl.searchParams.has('base_url'); 63 | 64 | if (hadParams) { 65 | cleanUrl.searchParams.delete('apikey'); 66 | cleanUrl.searchParams.delete('api_key'); 67 | cleanUrl.searchParams.delete('key'); 68 | cleanUrl.searchParams.delete('model'); 69 | cleanUrl.searchParams.delete('provider'); 70 | cleanUrl.searchParams.delete('baseUrl'); 71 | cleanUrl.searchParams.delete('base_url'); 72 | 73 | window.history.replaceState({}, document.title, cleanUrl.toString()); 74 | } 75 | } catch (error) { 76 | console.error("Error cleaning URL parameters:", error); 77 | } 78 | } 79 | 80 | /** 81 | * Detect provider from API key format 82 | */ 83 | function detectProviderFromApiKey(apiKey: string): AIProvider { 84 | if (apiKey.startsWith('sk-')) return 'openai'; 85 | if (apiKey.startsWith('AIza')) return 'gemini'; 86 | if (apiKey.startsWith('sk-ant-')) return 'anthropic'; 87 | if (apiKey.startsWith('sk-') && apiKey.includes('deepseek')) return 'deepseek'; 88 | 89 | // Default to OpenAI for backward compatibility 90 | return 'openai'; 91 | } 92 | 93 | /** 94 | * Validate API key format for provider 95 | */ 96 | function validateApiKeyFormat(apiKey: string, provider: AIProvider): boolean { 97 | switch (provider) { 98 | case 'openai': 99 | return apiKey.startsWith('sk-') && apiKey.length > 20; 100 | case 'gemini': 101 | return apiKey.startsWith('AIza') && apiKey.length > 20; 102 | case 'anthropic': 103 | return apiKey.startsWith('sk-ant-') && apiKey.length > 20; 104 | case 'deepseek': 105 | return apiKey.startsWith('sk-') && apiKey.length > 20; 106 | case 'ollama': 107 | case 'browser': 108 | return true; // These don't require API keys 109 | default: 110 | return apiKey.length > 10; // Generic validation 111 | } 112 | } 113 | 114 | /** 115 | * Process API key and provider from URL and store it immediately 116 | * This runs before version control to preserve the API key during migrations 117 | */ 118 | export function processApiKeyFromUrl(): void { 119 | const { apiKey, model, provider: urlProvider, baseUrl } = extractApiKeyFromUrl(); 120 | 121 | if (apiKey) { 122 | // Detect or use specified provider 123 | const detectedProvider = urlProvider || detectProviderFromApiKey(apiKey); 124 | 125 | // Validate API key format for the provider 126 | if (validateApiKeyFormat(apiKey, detectedProvider)) { 127 | try { 128 | // Get current multi-provider settings or use defaults 129 | const currentMultiSettings = localStorage.getItem('studorama-multi-provider-settings', { 130 | currentProvider: 'openai', 131 | providers: { 132 | openai: { apiKey: '', model: 'gpt-4o-mini' }, 133 | gemini: { apiKey: '', model: 'gemini-1.5-flash' }, 134 | anthropic: { apiKey: '', model: 'claude-3-haiku-20240307' }, 135 | deepseek: { apiKey: '', model: 'deepseek-chat' }, 136 | ollama: { apiKey: '', model: 'llama3.1:8b', baseUrl: 'http://localhost:11434/v1' }, 137 | browser: { apiKey: '', model: 'browser-ai' }, 138 | } 139 | }); 140 | 141 | // Get provider config for default model 142 | const providerConfig = getProviderConfig(detectedProvider); 143 | const defaultModel = model || providerConfig.defaultModel; 144 | 145 | // Update the specific provider settings 146 | const updatedProviderSettings = { 147 | ...currentMultiSettings.providers[detectedProvider], 148 | apiKey: apiKey, 149 | model: defaultModel, 150 | ...(baseUrl && { baseUrl: baseUrl }) 151 | }; 152 | 153 | // Validate the updated settings 154 | const validation = validateProviderConfig(detectedProvider, updatedProviderSettings); 155 | 156 | if (validation.valid) { 157 | const updatedMultiSettings = { 158 | ...currentMultiSettings, 159 | currentProvider: detectedProvider, 160 | providers: { 161 | ...currentMultiSettings.providers, 162 | [detectedProvider]: updatedProviderSettings 163 | } 164 | }; 165 | 166 | // Store multi-provider settings 167 | localStorage.setItem('studorama-multi-provider-settings', updatedMultiSettings); 168 | 169 | // Also update legacy settings for backward compatibility 170 | const legacySettings = localStorage.getItem(STORAGE_KEYS.API_SETTINGS, { 171 | openaiApiKey: '', 172 | model: DEFAULTS.MODEL, 173 | customPrompts: { 174 | multipleChoice: '', 175 | dissertative: '', 176 | evaluation: '', 177 | elaborativePrompt: '', 178 | retrievalPrompt: '' 179 | }, 180 | preloadQuestions: DEFAULTS.PRELOAD_QUESTIONS 181 | }); 182 | 183 | // Update legacy settings if it's OpenAI 184 | if (detectedProvider === 'openai') { 185 | const updatedLegacySettings = { 186 | ...legacySettings, 187 | openaiApiKey: apiKey, 188 | model: defaultModel 189 | }; 190 | localStorage.setItem(STORAGE_KEYS.API_SETTINGS, updatedLegacySettings); 191 | } 192 | 193 | // Set a flag to show notification later 194 | localStorage.setItem('studorama-api-key-from-url', JSON.stringify({ 195 | provider: detectedProvider, 196 | model: defaultModel, 197 | hasBaseUrl: !!baseUrl 198 | })); 199 | 200 | console.log(`${detectedProvider.toUpperCase()} API key extracted and stored from URL`); 201 | } else { 202 | console.warn(`Invalid ${detectedProvider.toUpperCase()} configuration:`, validation.errors); 203 | // Set error flag for notification 204 | localStorage.setItem('studorama-api-key-error', JSON.stringify({ 205 | provider: detectedProvider, 206 | errors: validation.errors 207 | })); 208 | } 209 | 210 | // Clean URL parameters immediately 211 | cleanUrlParameters(); 212 | } catch (error) { 213 | console.error('Error storing API key from URL:', error); 214 | cleanUrlParameters(); 215 | } 216 | } else { 217 | console.warn(`Invalid API key format for ${detectedProvider.toUpperCase()} in URL parameter`); 218 | // Set error flag for notification 219 | localStorage.setItem('studorama-api-key-error', JSON.stringify({ 220 | provider: detectedProvider, 221 | errors: [`Invalid API key format for ${detectedProvider.toUpperCase()}`] 222 | })); 223 | cleanUrlParameters(); 224 | } 225 | } 226 | } 227 | 228 | /** 229 | * Hook to extract and use API key from URL parameters 230 | * This now handles multi-provider settings and shows appropriate notifications 231 | */ 232 | export function useApiKeyFromUrl() { 233 | // Load both legacy and multi-provider settings 234 | const [apiSettings] = useLocalStorage(STORAGE_KEYS.API_SETTINGS, { 235 | openaiApiKey: "", 236 | model: DEFAULTS.MODEL, 237 | customPrompts: { 238 | multipleChoice: "", 239 | dissertative: "", 240 | evaluation: "", 241 | elaborativePrompt: "", 242 | retrievalPrompt: "", 243 | }, 244 | preloadQuestions: DEFAULTS.PRELOAD_QUESTIONS, 245 | }); 246 | 247 | const [multiProviderSettings] = useLocalStorage('studorama-multi-provider-settings', { 248 | currentProvider: 'openai', 249 | providers: { 250 | openai: { apiKey: '', model: 'gpt-4o-mini' }, 251 | gemini: { apiKey: '', model: 'gemini-1.5-flash' }, 252 | anthropic: { apiKey: '', model: 'claude-3-haiku-20240307' }, 253 | deepseek: { apiKey: '', model: 'deepseek-chat' }, 254 | ollama: { apiKey: '', model: 'llama3.1:8b', baseUrl: 'http://localhost:11434/v1' }, 255 | browser: { apiKey: '', model: 'browser-ai' }, 256 | } 257 | }); 258 | 259 | const { t } = useLanguage(); 260 | 261 | useEffect(() => { 262 | // Check if we should show the API key success notification 263 | const successData = localStorage.getItem('studorama-api-key-from-url', null); 264 | 265 | if (successData) { 266 | // Remove the flag immediately to prevent showing again 267 | localStorage.removeItem("studorama-api-key-from-url"); 268 | 269 | // Small delay to ensure translations are loaded 270 | setTimeout(() => { 271 | const providerName = successData.provider?.toUpperCase() || 'AI'; 272 | const modelInfo = successData.model ? ` ${t.usingModel} ${successData.model}` : ''; 273 | const baseUrlInfo = successData.hasBaseUrl ? ' with custom base URL' : ''; 274 | 275 | showNotification({ 276 | type: "success", 277 | title: t.apiKeyConfigured, 278 | message: `${providerName} ${t.apiKeyConfiguredDesc}.${modelInfo}${baseUrlInfo}`, 279 | duration: 6000, 280 | }); 281 | }, 1000); 282 | } 283 | 284 | // Check if we should show an error notification 285 | const errorData = localStorage.getItem('studorama-api-key-error', null); 286 | 287 | if (errorData) { 288 | // Remove the flag immediately to prevent showing again 289 | localStorage.removeItem('studorama-api-key-error'); 290 | 291 | // Small delay to ensure translations are loaded 292 | setTimeout(() => { 293 | const providerName = errorData.provider?.toUpperCase() || 'AI'; 294 | const errors = errorData.errors?.join(', ') || 'Unknown error'; 295 | 296 | showNotification({ 297 | type: 'error', 298 | title: t.invalidApiKey, 299 | message: `${providerName}: ${errors}`, 300 | duration: 8000, 301 | }); 302 | }, 1000); 303 | } 304 | }, [t]); 305 | 306 | // Determine if we have a valid API key for the current provider 307 | const currentProvider = multiProviderSettings.currentProvider; 308 | const currentProviderSettings = multiProviderSettings.providers[currentProvider]; 309 | const providerConfig = getProviderConfig(currentProvider); 310 | 311 | // For providers that don't require API keys, consider them as "having" a key 312 | const hasApiKey = !providerConfig.requiresApiKey || !!currentProviderSettings.apiKey; 313 | 314 | return { 315 | // Legacy compatibility 316 | apiSettings, 317 | hasApiKey: hasApiKey, 318 | 319 | // Multi-provider support 320 | multiProviderSettings, 321 | currentProvider, 322 | currentProviderSettings, 323 | providerConfig 324 | }; 325 | } 326 | -------------------------------------------------------------------------------- /src/core/types/language.types.ts: -------------------------------------------------------------------------------- 1 | export type Language = 'en-US' | 'pt-BR'; 2 | 3 | export interface LanguageSettings { 4 | language: Language; 5 | } 6 | 7 | export interface Translations { 8 | // Navigation 9 | dashboard: string; 10 | study: string; 11 | history: string; 12 | settings: string; 13 | 14 | // Dashboard 15 | welcomeTitle: string; 16 | welcomeSubtitle: string; 17 | setupRequired: string; 18 | configureApiKey: string; 19 | readyToStudy: string; 20 | usingModel: string; 21 | totalSessions: string; 22 | completed: string; 23 | averageScore: string; 24 | startNewSession: string; 25 | beginNewSession: string; 26 | viewHistory: string; 27 | reviewPastSessions: string; 28 | recentSessions: string; 29 | questions: string; 30 | inProgress: string; 31 | score: string; 32 | 33 | // Study Session 34 | startNewStudySession: string; 35 | configureSession: string; 36 | studySubject: string; 37 | subjectPlaceholder: string; 38 | subjectModifiers: string; 39 | subjectModifiersPlaceholder: string; 40 | addModifier: string; 41 | removeModifier: string; 42 | questionType: string; 43 | multipleChoice: string; 44 | quickAssessment: string; 45 | dissertative: string; 46 | deepAnalysis: string; 47 | mixed: string; 48 | interleavedPractice: string; 49 | learningTechniques: string; 50 | makeItStickBased: string; 51 | spacedRepetitionDesc: string; 52 | interleavingDesc: string; 53 | elaborativeInterrogationDesc: string; 54 | retrievalPracticeDesc: string; 55 | rememberMyChoice: string; 56 | rememberLearningTechniques: string; 57 | readyToLearn: string; 58 | startEnhancedSession: string; 59 | configureApiKeyFirst: string; 60 | generatingQuestion: string; 61 | using: string; 62 | currentScore: string; 63 | question: string; 64 | easy: string; 65 | medium: string; 66 | hard: string; 67 | confidenceQuestion: string; 68 | notConfident: string; 69 | veryConfident: string; 70 | excellent: string; 71 | keepLearning: string; 72 | aiEvaluation: string; 73 | modelAnswer: string; 74 | elaborativePrompt: string; 75 | explainReasoning: string; 76 | selfExplanationPrompt: string; 77 | connectKnowledge: string; 78 | endSession: string; 79 | submitAnswer: string; 80 | evaluating: string; 81 | tryAgain: string; 82 | nextQuestion: string; 83 | pauseSession: string; 84 | resumeSession: string; 85 | 86 | // Session History 87 | studyHistory: string; 88 | reviewProgress: string; 89 | newSession: string; 90 | noSessionsYet: string; 91 | startFirstSession: string; 92 | allSessions: string; 93 | questionsAnswered: string; 94 | date: string; 95 | accuracy: string; 96 | continue: string; 97 | viewDetails: string; 98 | correctAnswers: string; 99 | deleteAllSessions: string; 100 | deleteAllSessionsConfirm: string; 101 | deleteAllSessionsWarning: string; 102 | sessionsDeleted: string; 103 | 104 | // Session Details 105 | sessionNotFound: string; 106 | sessionNotFoundDesc: string; 107 | backToHistory: string; 108 | sessionDetails: string; 109 | questionsAndAnswers: string; 110 | correct: string; 111 | yourAnswer: string; 112 | feedback: string; 113 | attempts: string; 114 | attempt: string; 115 | continueSession: string; 116 | 117 | // Settings 118 | settingsTitle: string; 119 | configurePreferences: string; 120 | apiConfiguration: string; 121 | openaiApiConfig: string; 122 | openaiApiKey: string; 123 | apiKeyStored: string; 124 | openaiModel: string; 125 | howToGetApiKey: string; 126 | openaiPlatform: string; 127 | signInOrCreate: string; 128 | createSecretKey: string; 129 | copyPasteKey: string; 130 | aiPrompts: string; 131 | aiPromptsCustomization: string; 132 | customizeGeneration: string; 133 | resetToDefaults: string; 134 | multipleChoicePrompt: string; 135 | dissertativePrompt: string; 136 | answerEvaluationPrompt: string; 137 | elaborativeInterrogationPrompt: string; 138 | retrievalPracticePrompt: string; 139 | subjectPlaceholder2: string; 140 | questionPlaceholder: string; 141 | userAnswerPlaceholder: string; 142 | modelAnswerPlaceholder: string; 143 | learningTechniquesTab: string; 144 | learningTechniquesSettings: string; 145 | manageLearningTechniques: string; 146 | defaultLearningTechniques: string; 147 | rememberChoiceForSessions: string; 148 | rememberChoiceDescription: string; 149 | unsetRememberChoice: string; 150 | makeItStickScience: string; 151 | spacedRepetition: string; 152 | spacedRepetitionFull: string; 153 | spacedRepetitionHow: string; 154 | interleaving: string; 155 | interleavingFull: string; 156 | interleavingHow: string; 157 | elaborativeInterrogation: string; 158 | elaborativeInterrogationFull: string; 159 | elaborativeInterrogationHow: string; 160 | selfExplanation: string; 161 | selfExplanationFull: string; 162 | selfExplanationHow: string; 163 | desirableDifficulties: string; 164 | desirableDifficultiesFull: string; 165 | desirableDifficultiesHow: string; 166 | retrievalPractice: string; 167 | retrievalPracticeFull: string; 168 | retrievalPracticeHow: string; 169 | generationEffect: string; 170 | researchBasedBenefits: string; 171 | improvedRetention: string; 172 | betterTransfer: string; 173 | deeperUnderstanding: string; 174 | metacognitiveAwareness: string; 175 | language: string; 176 | selectLanguage: string; 177 | about: string; 178 | aboutStudorama: string; 179 | aiPoweredPlatform: string; 180 | createdBy: string; 181 | studoramaDescription: string; 182 | github: string; 183 | linkedin: string; 184 | coreFeatures: string; 185 | aiGeneratedQuestions: string; 186 | mixedQuestionTypes: string; 187 | spacedRepetitionScheduling: string; 188 | elaborativePrompts: string; 189 | selfExplanationExercises: string; 190 | confidenceTracking: string; 191 | sessionHistoryAnalytics: string; 192 | learningScience: string; 193 | makeItStickResearch: string; 194 | retrievalPracticeImplementation: string; 195 | desirableDifficultiesIntegration: string; 196 | generationEffectUtilization: string; 197 | metacognitiveStrategyTraining: string; 198 | evidenceBasedSpacing: string; 199 | cognitiveLoadOptimization: string; 200 | privacySecurity: string; 201 | privacyDescription: string; 202 | scientificFoundation: string; 203 | scientificDescription: string; 204 | openSourceProject: string; 205 | openSourceDescription: string; 206 | viewOnGitHub: string; 207 | contributeToProject: string; 208 | reportIssue: string; 209 | requestFeature: string; 210 | saveSettings: string; 211 | saved: string; 212 | configurationStatus: string; 213 | configured: string; 214 | notConfigured: string; 215 | selectedModel: string; 216 | enhancedStudyMode: string; 217 | ready: string; 218 | requiresApiKey: string; 219 | 220 | // AI Provider Settings 221 | aiProvider: string; 222 | aiProviderSelection: string; 223 | selectAiProvider: string; 224 | providerConfiguration: string; 225 | configureProvider: string; 226 | providerStatus: string; 227 | providerReady: string; 228 | providerNotConfigured: string; 229 | providerInvalidConfig: string; 230 | invalidConfiguration: string; 231 | apiKeyRequired: string; 232 | invalidApiKeyFormat: string; 233 | invalidBaseUrl: string; 234 | modelSelection: string; 235 | selectModel: string; 236 | modelInfo: string; 237 | costTier: string; 238 | contextWindow: string; 239 | capabilities: string; 240 | recommended: string; 241 | tokens: string; 242 | 243 | // Cost Tiers 244 | free: string; 245 | low: string; 246 | high: string; 247 | 248 | // Provider Names & Descriptions 249 | openaiProvider: string; 250 | openaiDescription: string; 251 | geminiProvider: string; 252 | geminiDescription: string; 253 | anthropicProvider: string; 254 | anthropicDescription: string; 255 | deepseekProvider: string; 256 | deepseekDescription: string; 257 | ollamaProvider: string; 258 | ollamaDescription: string; 259 | browserProvider: string; 260 | browserDescription: string; 261 | 262 | // Model Names & Descriptions 263 | gpt4o: string; 264 | gpt4oDescription: string; 265 | gpt4oMini: string; 266 | gpt4oMiniDescription: string; 267 | gpt4Turbo: string; 268 | gpt4TurboDescription: string; 269 | gpt4: string; 270 | gpt4Description: string; 271 | gpt35Turbo: string; 272 | gpt35TurboDescription: string; 273 | 274 | gemini15Pro: string; 275 | gemini15ProDescription: string; 276 | gemini15Flash: string; 277 | gemini15FlashDescription: string; 278 | geminiPro: string; 279 | geminiProDescription: string; 280 | 281 | claude35Sonnet: string; 282 | claude35SonnetDescription: string; 283 | claude3Haiku: string; 284 | claude3HaikuDescription: string; 285 | claude3Opus: string; 286 | claude3OpusDescription: string; 287 | 288 | deepseekChat: string; 289 | deepseekChatDescription: string; 290 | deepseekCoder: string; 291 | deepseekCoderDescription: string; 292 | 293 | llama318b: string; 294 | llama318bDescription: string; 295 | llama3170b: string; 296 | llama3170bDescription: string; 297 | mistral7b: string; 298 | mistral7bDescription: string; 299 | codellama13b: string; 300 | codellama13bDescription: string; 301 | 302 | browserAi: string; 303 | browserAiDescription: string; 304 | 305 | // API Key Labels 306 | openaiApiKeyLabel: string; 307 | geminiApiKeyLabel: string; 308 | anthropicApiKeyLabel: string; 309 | deepseekApiKeyLabel: string; 310 | 311 | // Setup Instructions 312 | setupInstructions: string; 313 | howToGetKey: string; 314 | 315 | // OpenAI Setup 316 | visitPlatformOpenai: string; 317 | signInOpenai: string; 318 | navigateApiKeys: string; 319 | createNewSecretKey: string; 320 | 321 | // Gemini Setup 322 | visitAiStudio: string; 323 | signInGoogle: string; 324 | navigateApiKeysGoogle: string; 325 | createNewApiKey: string; 326 | 327 | // Anthropic Setup 328 | visitConsoleAnthropic: string; 329 | signInAnthropic: string; 330 | navigateApiKeysAnthropic: string; 331 | createNewApiKeyAnthropic: string; 332 | 333 | // DeepSeek Setup 334 | visitPlatformDeepseek: string; 335 | signInDeepseek: string; 336 | navigateApiKeysDeepseek: string; 337 | createNewApiKeyDeepseek: string; 338 | 339 | // Ollama Setup 340 | installOllama: string; 341 | runOllamaServe: string; 342 | pullModel: string; 343 | configureBaseUrl: string; 344 | 345 | // Browser AI Setup 346 | enableExperimentalFeatures: string; 347 | experimentalFeature: string; 348 | limitedPerformance: string; 349 | 350 | // Configuration Fields 351 | baseUrl: string; 352 | baseUrlPlaceholder: string; 353 | model: string; 354 | 355 | // Data Management 356 | dataManagement: string; 357 | manageYourData: string; 358 | deleteAllData: string; 359 | deleteAllDataConfirm: string; 360 | deleteAllDataWarning: string; 361 | allDataDeleted: string; 362 | deleteAllDataDesc: string; 363 | 364 | // Language Switch Modal 365 | languageChange: string; 366 | resetPromptsOption: string; 367 | resetPromptsDescription: string; 368 | rememberChoice: string; 369 | confirmChange: string; 370 | cancel: string; 371 | languageSwitchPreferences: string; 372 | manageLanguagePreferences: string; 373 | resetLanguagePreferences: string; 374 | languagePreferencesReset: string; 375 | 376 | // OpenAI Models 377 | gpt4oRecommended: string; 378 | latestMostCapable: string; 379 | fasterCostEffective: string; 380 | highPerformance: string; 381 | previousGeneration: string; 382 | fastEconomical: string; 383 | 384 | // Pricing 385 | supportStudorama: string; 386 | supportStudoramaDesc: string; 387 | freeForever: string; 388 | freeForeverDesc: string; 389 | noAccountRequired: string; 390 | startLearningImmediately: string; 391 | noAccountRequiredDesc: string; 392 | monthlySponsorship: string; 393 | supportFreeEducation: string; 394 | helpImprovePlatform: string; 395 | recognitionAsSupporter: string; 396 | helpKeepPlatformAccountless: string; 397 | becomeSupporter: string; 398 | externalCheckout: string; 399 | accountOptional: string; 400 | whySponsorStudorama: string; 401 | keepItFree: string; 402 | keepItFreeDesc: string; 403 | fundDevelopment: string; 404 | fundDevelopmentDesc: string; 405 | serverCosts: string; 406 | serverCostsDesc: string; 407 | privacyFirst: string; 408 | privacyFirstDesc: string; 409 | startLearningInstantly: string; 410 | noBarriersToLearning: string; 411 | noEmailRequired: string; 412 | noPasswordToRemember: string; 413 | noVerificationSteps: string; 414 | noPersonalDataCollection: string; 415 | startStudyingInSeconds: string; 416 | privacyFocused: string; 417 | dataStaysInBrowser: string; 418 | noTrackingOrAnalytics: string; 419 | yourApiKeyStaysLocal: string; 420 | completeAnonymity: string; 421 | gdprCompliantByDesign: string; 422 | transparencyTrust: string; 423 | transparencyTrustDesc: string; 424 | mostPopular: string; 425 | advanced: string; 426 | standard: string; 427 | basic: string; 428 | advancedDesc: string; 429 | standardDesc: string; 430 | basicDesc: string; 431 | 432 | // Theme Support 433 | theme: string; 434 | themes: string; 435 | appearance: string; 436 | selectTheme: string; 437 | themeSettings: string; 438 | customizeAppearance: string; 439 | 440 | // API Key Notifications 441 | apiKeyConfigured: string; 442 | apiKeyConfiguredDesc: string; 443 | invalidApiKey: string; 444 | invalidApiKeyDesc: string; 445 | apiKeyFromUrl: string; 446 | apiKeyPreserved: string; 447 | 448 | // Version Control 449 | appUpdated: string; 450 | versionUpdated: string; 451 | dataRefreshed: string; 452 | initializingApp: string; 453 | checkingUpdates: string; 454 | 455 | // Common 456 | loading: string; 457 | error: string; 458 | retry: string; 459 | delete: string; 460 | edit: string; 461 | close: string; 462 | yes: string; 463 | no: string; 464 | save: string; 465 | } --------------------------------------------------------------------------------