60 | {/* Theme Selector Button */}
61 |
setIsOpen(!isOpen)}
63 | className="flex items-center space-x-2 px-3 py-2 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-offset-2"
64 | style={{
65 | backgroundColor: themeConfig.colors.surface,
66 | borderColor: themeConfig.colors.border,
67 | color: themeConfig.colors.text,
68 | '--tw-ring-color': themeConfig.colors.primary,
69 | }}
70 | aria-label={language === 'pt-BR' ? 'Selecionar tema' : 'Select theme'}
71 | >
72 |
73 | {showLabel && (
74 |
75 | {t.theme}
76 |
77 | )}
78 |
87 |
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 | setSelectedCategory('all')}
128 | className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
129 | selectedCategory === 'all'
130 | ? 'text-white'
131 | : ''
132 | }`}
133 | style={{
134 | backgroundColor: selectedCategory === 'all' ? themeConfig.colors.primary : 'transparent',
135 | color: selectedCategory === 'all' ? '#ffffff' : themeConfig.colors.textSecondary,
136 | }}
137 | >
138 | {language === 'pt-BR' ? 'Todos' : 'All'}
139 |
140 | {categories.map((category) => {
141 | const Icon = categoryIcons[category];
142 | return (
143 | setSelectedCategory(category)}
146 | className={`flex items-center space-x-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors`}
147 | style={{
148 | backgroundColor: selectedCategory === category ? themeConfig.colors.primary : 'transparent',
149 | color: selectedCategory === category ? '#ffffff' : themeConfig.colors.textSecondary,
150 | }}
151 | >
152 |
153 | {getLocalizedCategoryName(category, language)}
154 |
155 | );
156 | })}
157 |
158 |
159 |
160 | {/* Theme Grid - Scrollable */}
161 |
162 |
163 | {filteredThemes.map((theme) => (
164 |
handleThemeChange(theme.id)}
167 | className={`relative p-3 rounded-lg border-2 transition-all duration-200 hover:scale-105 ${
168 | currentTheme === theme.id
169 | ? 'shadow-sm'
170 | : ''
171 | }`}
172 | style={{
173 | background: theme.gradients.card,
174 | borderColor: currentTheme === theme.id ? theme.colors.primary : theme.colors.border,
175 | }}
176 | >
177 | {/* Theme Preview */}
178 |
179 | {getThemePreview(theme)}
180 |
181 |
182 | {/* Theme Info */}
183 |
184 |
185 |
189 | {getLocalizedThemeName(theme.id, language)}
190 |
191 | {currentTheme === theme.id && (
192 |
193 | )}
194 |
195 |
199 | {getLocalizedThemeDescription(theme.id, language)}
200 |
201 |
202 | {/* Category Badge */}
203 |
204 |
211 | {React.createElement(categoryIcons[theme.category], {
212 | className: "w-3 h-3"
213 | })}
214 | {getLocalizedCategoryName(theme.category, language)}
215 |
216 |
217 |
218 |
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 |
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 |
setIsOpen(!isOpen)}
114 | className="flex items-center space-x-2 px-3 py-2 rounded-lg border transition-all duration-200 focus:ring-2 focus:ring-offset-2"
115 | style={{
116 | backgroundColor: themeConfig.colors.surface,
117 | borderColor: themeConfig.colors.border,
118 | color: themeConfig.colors.text,
119 | '--tw-ring-color': themeConfig.colors.primary,
120 | }}
121 | aria-label={language === 'pt-BR' ? 'Selecionar tema' : 'Select theme'}
122 | >
123 |
124 | {showLabel && (
125 |
126 | {t.theme}
127 |
128 | )}
129 |
138 |
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 | setSelectedCategory('all')}
179 | className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
180 | selectedCategory === 'all'
181 | ? 'text-white'
182 | : ''
183 | }`}
184 | style={{
185 | backgroundColor: selectedCategory === 'all' ? themeConfig.colors.primary : 'transparent',
186 | color: selectedCategory === 'all' ? '#ffffff' : themeConfig.colors.textSecondary,
187 | }}
188 | >
189 | {language === 'pt-BR' ? 'Todos' : 'All'}
190 |
191 | {categories.map((category) => {
192 | const Icon = categoryIcons[category];
193 | return (
194 | setSelectedCategory(category)}
197 | className={`flex items-center space-x-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors`}
198 | style={{
199 | backgroundColor: selectedCategory === category ? themeConfig.colors.primary : 'transparent',
200 | color: selectedCategory === category ? '#ffffff' : themeConfig.colors.textSecondary,
201 | }}
202 | >
203 |
204 | {getCategoryLabel(category)}
205 |
206 | );
207 | })}
208 |
209 |
210 |
211 | {/* Theme Grid - Scrollable */}
212 |
213 |
214 | {filteredThemes.map((theme) => (
215 |
handleThemeChange(theme.id)}
218 | className={`relative p-3 rounded-lg border-2 transition-all duration-200 hover:scale-105 ${
219 | currentTheme === theme.id
220 | ? 'shadow-sm'
221 | : ''
222 | }`}
223 | style={{
224 | background: theme.gradients.card,
225 | borderColor: currentTheme === theme.id ? theme.colors.primary : theme.colors.border,
226 | }}
227 | >
228 | {/* Theme Preview */}
229 |
230 | {getThemePreview(theme)}
231 |
232 |
233 | {/* Theme Info */}
234 |
235 |
236 |
240 | {getLocalizedThemeName(theme)}
241 |
242 | {currentTheme === theme.id && (
243 |
244 | )}
245 |
246 |
250 | {getLocalizedThemeDescription(theme)}
251 |
252 |
253 | {/* Category Badge */}
254 |
255 |
262 | {React.createElement(categoryIcons[theme.category], {
263 | className: "w-3 h-3"
264 | })}
265 | {getCategoryLabel(theme.category)}
266 |
267 |
268 |
269 |
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 |
navigate('/history')}
49 | className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
50 | >
51 |
52 |
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 | }
--------------------------------------------------------------------------------