amauricunha commited on
Commit
03154c7
·
verified ·
1 Parent(s): c6c150b

Upload 4 files

Browse files
Files changed (3) hide show
  1. templates/admin.html +0 -1
  2. templates/base.html +39 -527
  3. templates/index.html +25 -408
templates/admin.html CHANGED
@@ -106,7 +106,6 @@
106
  <!-- Toast Notifications -->
107
  <div id="toastContainer" class="fixed top-4 right-4 z-50"></div>
108
 
109
- <script src="https://cdn.tailwindcss.com"></script>
110
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
111
  <script>
112
  // Minimal, consolidated admin UI JS that talks to existing backend endpoints
 
106
  <!-- Toast Notifications -->
107
  <div id="toastContainer" class="fixed top-4 right-4 z-50"></div>
108
 
 
109
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
110
  <script>
111
  // Minimal, consolidated admin UI JS that talks to existing backend endpoints
templates/base.html CHANGED
@@ -5,554 +5,66 @@
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
  <title>{% block title %}English Helper{% endblock %}</title>
7
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
 
 
 
8
  <style>
9
  /* Small helper styles for the header/navigation */
10
  .site-nav { background: #ffffff; border-bottom: 1px solid #e5e7eb; }
11
  .site-nav a { margin-right: 1rem; color: #374151; text-decoration: none; font-weight: 600; }
12
- .site-nav a:hover { color: #1d4ed8; }
13
- .container { max-width: 1100px; margin: 0 auto; padding: 1rem; }
14
  </style>
15
  </head>
16
- <body class="bg-gray-50 text-gray-800">
17
- <header class="site-nav">
18
- <div class="container flex items-center justify-between">
19
  <div class="flex items-center gap-4">
20
- <a href="/" class="flex items-center gap-2"><span style="font-size:20px">🎓</span><strong>English Helper</strong></a>
21
- <nav class="hidden md:flex items-center">
22
- <a href="/">Home</a>
23
- <a href="/admin">Admin</a>
24
- <a href="/status">Status</a>
25
- </nav>
26
  </div>
27
- <div>
28
- <!-- optional right-side links -->
 
 
29
  </div>
30
  </div>
31
- </header>
32
 
33
- <!-- Demo mode banner -->
34
- <div id="demoBanner" class="container mt-2">
35
- <div class="bg-yellow-50 border border-yellow-200 rounded p-3 text-sm flex justify-between items-center" style="display:none;" role="status">
36
- <div>
37
- <strong>Demo Mode</strong> — Running with in-memory data. Changes won't persist after a restart.
38
- </div>
39
- <div>
40
- <button id="dismissDemo" class="px-3 py-1 bg-yellow-200 rounded text-sm">Dismiss</button>
41
- </div>
42
- </div>
43
  </div>
44
 
45
- <main class="container mt-6">
46
- {% block content %}{% endblock %}
 
 
47
  </main>
48
 
49
- <!-- Place for page-specific scripts -->
50
  {% block scripts %}{% endblock %}
51
- <script>
52
- (async function(){
53
- try {
54
- const dismissed = localStorage.getItem('eh_demo_dismissed');
55
- if (dismissed === '1') return;
56
- const res = await fetch('/system/status');
57
- if (!res.ok) return;
58
- const json = await res.json();
59
- if (json.storage === 'in_memory' || json.demo_mode) {
60
- const banner = document.getElementById('demoBanner').querySelector('div');
61
- if (banner) {
62
- banner.style.display = 'flex';
63
- document.getElementById('dismissDemo').addEventListener('click', () => {
64
- banner.style.display = 'none';
65
- localStorage.setItem('eh_demo_dismissed', '1');
66
- });
67
- }
68
- }
69
- } catch (e) {
70
- // ignore
71
- }
72
- })();
73
- </script>
74
- </body>
75
- </html>
76
- </div>
77
- </div>
78
- `).join('')}
79
- </div>
80
- </div>
81
- `;
82
-
83
- modal.classList.remove('hidden');
84
- }
85
 
86
- function closeUserModal() {
87
- document.getElementById('userDetailModal').classList.add('hidden');
88
- }
89
-
90
- async function deleteUser(userId, email) {
91
- if (!confirm(`Are you sure you want to delete user "${email}" and all their data? This action cannot be undone.`)) {
92
- return;
93
- }
94
-
95
  try {
96
- const response = await fetch(`/admin/users/${userId}`, {
97
- method: 'DELETE'
 
 
 
 
 
 
98
  });
99
-
100
- const data = await response.json();
101
-
102
- if (data.success) {
103
- showToast('User deleted successfully', 'success');
104
- loadUsers(); // Refresh the table
105
- } else {
106
- showToast('Failed to delete user', 'error');
107
- }
108
- } catch (error) {
109
- console.error('Delete user error:', error);
110
- showToast('Failed to delete user', 'error');
111
- }
112
- }
113
-
114
- async function loadDatabaseSchema() {
115
- try {
116
- const response = await fetch('/admin/database/schema');
117
- const data = await response.json();
118
-
119
- if (data.success) {
120
- updateDatabaseSchema(data.schema);
121
- }
122
- } catch (error) {
123
- console.error('Schema loading error:', error);
124
- showToast('Failed to load database schema', 'error');
125
- }
126
- }
127
-
128
- function updateDatabaseSchema(schema) {
129
- const container = document.getElementById('databaseSchema');
130
-
131
- container.innerHTML = Object.entries(schema).map(([tableName, tableInfo]) => `
132
- <div class="border rounded-lg p-4">
133
- <div class="flex justify-between items-center mb-3">
134
- <h5 class="font-semibold text-lg">${tableName}</h5>
135
- <span class="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm">
136
- ${tableInfo.row_count} rows
137
- </span>
138
- </div>
139
- <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 text-sm">
140
- ${tableInfo.columns.map(col => `
141
- <div class="flex items-center space-x-2">
142
- <span class="font-medium">${col.name}</span>
143
- <span class="text-gray-500">${col.type}</span>
144
- ${col.primary_key ? '<span class="bg-yellow-100 text-yellow-800 px-1 rounded text-xs">PK</span>' : ''}
145
- ${col.not_null ? '<span class="bg-red-100 text-red-800 px-1 rounded text-xs">NOT NULL</span>' : ''}
146
- </div>
147
- `).join('')}
148
- </div>
149
- </div>
150
- `).join('');
151
- }
152
-
153
- async function loadTokenUsage() {
154
- try {
155
- // For now, create sample charts with existing data
156
- await createTokenUsageCharts();
157
- } catch (error) {
158
- console.error('Token usage loading error:', error);
159
- showToast('Failed to load token usage data', 'error');
160
- }
161
- }
162
-
163
- async function createTokenUsageCharts() {
164
- // Provider Usage Chart
165
- const providerCtx = document.getElementById('providerUsageChart').getContext('2d');
166
-
167
- new Chart(providerCtx, {
168
- type: 'bar',
169
- data: {
170
- labels: ['Groq', 'Gemini'],
171
- datasets: [{
172
- label: 'Input Tokens',
173
- data: [150000, 85000],
174
- backgroundColor: 'rgba(59, 130, 246, 0.8)',
175
- borderColor: 'rgba(59, 130, 246, 1)',
176
- borderWidth: 1
177
- }, {
178
- label: 'Output Tokens',
179
- data: [45000, 28000],
180
- backgroundColor: 'rgba(147, 51, 234, 0.8)',
181
- borderColor: 'rgba(147, 51, 234, 1)',
182
- borderWidth: 1
183
- }]
184
- },
185
- options: {
186
- responsive: true,
187
- plugins: {
188
- title: {
189
- display: true,
190
- text: 'Token Usage by Provider'
191
- }
192
- },
193
- scales: {
194
- y: {
195
- beginAtZero: true,
196
- title: {
197
- display: true,
198
- text: 'Tokens'
199
- }
200
- }
201
- }
202
- }
203
- });
204
-
205
- // Cost Breakdown Chart
206
- const costCtx = document.getElementById('costBreakdownChart').getContext('2d');
207
-
208
- new Chart(costCtx, {
209
- type: 'pie',
210
- data: {
211
- labels: ['Conversation', 'Content Analysis', 'Recommendations', 'Study Planning'],
212
- datasets: [{
213
- data: [40, 25, 20, 15],
214
- backgroundColor: [
215
- 'rgba(34, 197, 94, 0.8)',
216
- 'rgba(59, 130, 246, 0.8)',
217
- 'rgba(147, 51, 234, 0.8)',
218
- 'rgba(251, 146, 60, 0.8)'
219
- ],
220
- borderColor: [
221
- 'rgba(34, 197, 94, 1)',
222
- 'rgba(59, 130, 246, 1)',
223
- 'rgba(147, 51, 234, 1)',
224
- 'rgba(251, 146, 60, 1)'
225
- ],
226
- borderWidth: 2
227
- }]
228
- },
229
- options: {
230
- responsive: true,
231
- plugins: {
232
- title: {
233
- display: true,
234
- text: 'Cost Distribution by Operation Type'
235
- },
236
- legend: {
237
- position: 'bottom'
238
- }
239
- }
240
- }
241
- });
242
- }
243
-
244
- function refreshUsers() {
245
- currentPage = 1;
246
- loadUsers();
247
- }
248
-
249
- function previousPage() {
250
- if (currentPage > 1) {
251
- currentPage--;
252
- loadUsers();
253
- }
254
- }
255
-
256
- function nextPage() {
257
- if (usersData && currentPage < usersData.total_pages) {
258
- currentPage++;
259
- loadUsers();
260
- }
261
- }
262
-
263
- function createCharts(stats) {
264
- createUserGrowthChart(stats);
265
- createTokenCostChart(stats);
266
- }
267
-
268
- function createUserGrowthChart(stats) {
269
- const ctx = document.getElementById('userGrowthChart').getContext('2d');
270
-
271
- // Generate sample data for last 30 days
272
- const dates = [];
273
- const userData = [];
274
- const today = new Date();
275
-
276
- for (let i = 29; i >= 0; i--) {
277
- const date = new Date(today);
278
- date.setDate(date.getDate() - i);
279
- dates.push(date.toLocaleDateString());
280
-
281
- // Simulate growth data (would come from real analytics)
282
- userData.push(Math.max(0, stats.users?.total - Math.floor(Math.random() * i * 2)));
283
- }
284
-
285
- new Chart(ctx, {
286
- type: 'line',
287
- data: {
288
- labels: dates,
289
- datasets: [{
290
- label: 'Total Users',
291
- data: userData,
292
- borderColor: 'rgb(59, 130, 246)',
293
- backgroundColor: 'rgba(59, 130, 246, 0.1)',
294
- fill: true,
295
- tension: 0.4
296
- }]
297
- },
298
- options: {
299
- responsive: true,
300
- plugins: {
301
- title: {
302
- display: true,
303
- text: 'User Growth Over Time'
304
- }
305
- },
306
- interaction: {
307
- intersect: false,
308
- },
309
- scales: {
310
- x: {
311
- display: true,
312
- title: {
313
- display: true,
314
- text: 'Date'
315
- }
316
- },
317
- y: {
318
- display: true,
319
- title: {
320
- display: true,
321
- text: 'Users'
322
- }
323
- }
324
- }
325
- }
326
- });
327
- }
328
-
329
- function createTokenCostChart(stats) {
330
- const ctx = document.getElementById('tokenCostChart').getContext('2d');
331
-
332
- // Sample cost data
333
- const providers = ['Groq', 'Gemini'];
334
- const costs = [
335
- (stats.api_usage?.estimated_cost || 0) * 0.6, // Groq portion
336
- (stats.api_usage?.estimated_cost || 0) * 0.4 // Gemini portion
337
- ];
338
-
339
- new Chart(ctx, {
340
- type: 'doughnut',
341
- data: {
342
- labels: providers,
343
- datasets: [{
344
- label: 'Cost ($)',
345
- data: costs,
346
- backgroundColor: [
347
- 'rgba(59, 130, 246, 0.8)',
348
- 'rgba(147, 51, 234, 0.8)'
349
- ],
350
- borderColor: [
351
- 'rgba(59, 130, 246, 1)',
352
- 'rgba(147, 51, 234, 1)'
353
- ],
354
- borderWidth: 2
355
- }]
356
- },
357
- options: {
358
- responsive: true,
359
- plugins: {
360
- title: {
361
- display: true,
362
- text: 'API Costs by Provider'
363
- },
364
- legend: {
365
- position: 'bottom',
366
- }
367
- }
368
- }
369
- });
370
- }
371
-
372
- function exportUsers() {
373
- window.open('/admin/export/users', '_blank');
374
- showToast('Users data export started', 'success');
375
- }
376
-
377
- function exportTokens() {
378
- window.open('/admin/export/tokens', '_blank');
379
- showToast('Token usage data export started', 'success');
380
- }
381
-
382
- async function loadSystemHealth() {
383
- try {
384
- // Load system health metrics
385
- const healthResponse = await fetch('/admin/system/health');
386
- const healthData = await healthResponse.json();
387
-
388
- if (healthData.success) {
389
- updateSystemMetrics(healthData.health);
390
- }
391
-
392
- // Load system alerts
393
- const alertsResponse = await fetch('/admin/system/alerts');
394
- const alertsData = await alertsResponse.json();
395
-
396
- if (alertsData.success) {
397
- updateSystemAlerts(alertsData.alerts);
398
- }
399
- } catch (error) {
400
- console.error('System health loading error:', error);
401
- showToast('Failed to load system health data', 'error');
402
- }
403
- }
404
-
405
- function updateSystemMetrics(health) {
406
- // Memory metrics
407
- if (health.memory) {
408
- const memoryPercent = health.memory.percent || 0;
409
- const memoryUsed = Math.round(health.memory.used / 1024 / 1024 / 1024 * 100) / 100;
410
- const memoryTotal = Math.round(health.memory.total / 1024 / 1024 / 1024 * 100) / 100;
411
-
412
- document.getElementById('memoryMetrics').innerHTML = `
413
- <div class="space-y-2">
414
- <div class="flex justify-between">
415
- <span>Used:</span>
416
- <span>${memoryUsed}GB / ${memoryTotal}GB</span>
417
- </div>
418
- <div class="w-full bg-gray-200 rounded-full h-2">
419
- <div class="bg-blue-600 h-2 rounded-full" style="width: ${memoryPercent}%"></div>
420
- </div>
421
- <div class="text-xs text-gray-500">${memoryPercent.toFixed(1)}% used</div>
422
- </div>
423
- `;
424
- }
425
-
426
- // Disk metrics
427
- if (health.disk) {
428
- const diskPercent = health.disk.percent || 0;
429
- const diskUsed = Math.round(health.disk.used / 1024 / 1024 / 1024 * 100) / 100;
430
- const diskTotal = Math.round(health.disk.total / 1024 / 1024 / 1024 * 100) / 100;
431
-
432
- document.getElementById('diskMetrics').innerHTML = `
433
- <div class="space-y-2">
434
- <div class="flex justify-between">
435
- <span>Used:</span>
436
- <span>${diskUsed}GB / ${diskTotal}GB</span>
437
- </div>
438
- <div class="w-full bg-gray-200 rounded-full h-2">
439
- <div class="bg-green-600 h-2 rounded-full" style="width: ${diskPercent}%"></div>
440
- </div>
441
- <div class="text-xs text-gray-500">${diskPercent.toFixed(1)}% used</div>
442
- </div>
443
- `;
444
- }
445
-
446
- // Database metrics
447
- if (health.database) {
448
- document.getElementById('databaseMetrics').innerHTML = `
449
- <div class="space-y-2">
450
- <div class="flex justify-between">
451
- <span>Size:</span>
452
- <span>${health.database.size_mb}MB</span>
453
- </div>
454
- <div class="flex justify-between">
455
- <span>Uptime:</span>
456
- <span>${health.uptime || 'Unknown'}</span>
457
- </div>
458
- </div>
459
- `;
460
- }
461
-
462
- // Error logs
463
- if (health.recent_errors && health.recent_errors.length > 0) {
464
- document.getElementById('errorLogs').innerHTML = `
465
- <div class="space-y-2 max-h-64 overflow-y-auto">
466
- ${health.recent_errors.map(error => `
467
- <div class="p-2 bg-red-50 border border-red-200 rounded text-sm">
468
- <div class="flex justify-between items-start">
469
- <span class="font-medium text-red-800">${error.level}</span>
470
- <span class="text-red-600 text-xs">${error.timestamp}</span>
471
- </div>
472
- <div class="text-red-700 mt-1">${error.message}</div>
473
- <div class="text-red-600 text-xs mt-1">Module: ${error.module}</div>
474
- </div>
475
- `).join('')}
476
- </div>
477
- `;
478
- } else {
479
- document.getElementById('errorLogs').innerHTML = '<div class="text-center text-gray-500 py-4">No recent errors</div>';
480
- }
481
- }
482
-
483
- function updateSystemAlerts(alerts) {
484
- const container = document.getElementById('systemAlerts');
485
-
486
- if (alerts.length === 0) {
487
- container.innerHTML = '<div class="text-center text-green-600 py-4">✅ All systems normal</div>';
488
- return;
489
- }
490
-
491
- container.innerHTML = alerts.map(alert => {
492
- const bgColor = alert.type === 'error' ? 'bg-red-50 border-red-200' :
493
- alert.type === 'warning' ? 'bg-yellow-50 border-yellow-200' :
494
- 'bg-blue-50 border-blue-200';
495
- const textColor = alert.type === 'error' ? 'text-red-800' :
496
- alert.type === 'warning' ? 'text-yellow-800' :
497
- 'text-blue-800';
498
- const icon = alert.type === 'error' ? '🚨' :
499
- alert.type === 'warning' ? '⚠️' : 'ℹ️';
500
-
501
- return `
502
- <div class="p-3 ${bgColor} border rounded-lg">
503
- <div class="flex items-start gap-3">
504
- <span class="text-lg">${icon}</span>
505
- <div class="flex-1">
506
- <div class="font-medium ${textColor}">${alert.message}</div>
507
- <div class="text-sm ${textColor} opacity-75 mt-1">Action: ${alert.action}</div>
508
- </div>
509
- </div>
510
- </div>
511
- `;
512
- }).join('');
513
- }
514
-
515
- function formatDate(dateString) {
516
- if (!dateString) return 'Never';
517
- return new Date(dateString).toLocaleDateString() + ' ' + new Date(dateString).toLocaleTimeString();
518
- }
519
 
520
- function showToast(message, type = 'info') {
521
- const toast = document.createElement('div');
522
- toast.className = `fixed top-4 right-4 p-4 rounded-lg shadow-md z-50 ${
523
- type === 'success' ? 'bg-green-500' :
524
- type === 'error' ? 'bg-red-500' :
525
- type === 'warning' ? 'bg-yellow-500' : 'bg-blue-500'
526
- } text-white`;
527
-
528
- toast.textContent = message;
529
- document.body.appendChild(toast);
530
-
531
- setTimeout(() => {
532
- toast.remove();
533
- }, 5000);
534
  }
535
-
536
- // Add CSS for tab styling
537
- const style = document.createElement('style');
538
- style.textContent = `
539
- .admin-tab {
540
- padding: 0.5rem 1rem;
541
- border-bottom: 2px solid transparent;
542
- font-medium: 500;
543
- text-decoration: none;
544
- transition: all 0.2s;
545
- }
546
- .admin-tab:hover {
547
- text-decoration: none;
548
- border-bottom-color: #d1d5db;
549
- }
550
- .admin-tab.active {
551
- border-bottom-color: #3b82f6;
552
- color: #3b82f6;
553
- }
554
- `;
555
- document.head.appendChild(style);
556
  </script>
557
  </body>
558
  </html>
 
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
6
  <title>{% block title %}English Helper{% endblock %}</title>
7
  <link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
8
+ <!-- Tailwind CDN (kept for quick demo; consider building Tailwind for production) -->
9
+ <script src="https://cdn.tailwindcss.com"></script>
10
+ {% block head %}{% endblock %}
11
  <style>
12
  /* Small helper styles for the header/navigation */
13
  .site-nav { background: #ffffff; border-bottom: 1px solid #e5e7eb; }
14
  .site-nav a { margin-right: 1rem; color: #374151; text-decoration: none; font-weight: 600; }
 
 
15
  </style>
16
  </head>
17
+ <body class="bg-gray-50 min-h-screen">
18
+ <nav class="site-nav p-4">
19
+ <div class="max-w-7xl mx-auto flex justify-between items-center">
20
  <div class="flex items-center gap-4">
21
+ <a href="/" class="font-bold text-lg">English Helper</a>
 
 
 
 
 
22
  </div>
23
+ <div class="flex items-center gap-4">
24
+ <a href="/admin" class="text-sm">Admin</a>
25
+ <a href="/dashboard" class="text-sm">Dashboard</a>
26
+ <a href="/status" class="text-sm">Status</a>
27
  </div>
28
  </div>
29
+ </nav>
30
 
31
+ <!-- Demo banner (visible on HF spaces) -->
32
+ <div id="demoBanner" class="w-full text-center py-2 px-4" style="display:none; background:#eff6ff; color:#1e40af;">
33
+ 🚀 Demo Mode data is stored in-memory and will be lost on restart. <button id="dismissDemo" class="ml-3 underline">Dismiss</button>
 
 
 
 
 
 
 
34
  </div>
35
 
36
+ <main class="py-8">
37
+ <div class="max-w-6xl mx-auto px-4">
38
+ {% block content %}{% endblock %}
39
+ </div>
40
  </main>
41
 
 
42
  {% block scripts %}{% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
+ <script>
45
+ // Demo banner logic
46
+ (function(){
 
 
 
 
 
 
47
  try {
48
+ const isHF = window.location.hostname.includes('hf.space') || window.location.hostname.includes('huggingface.co');
49
+ if (!isHF) return;
50
+ if (localStorage.getItem('eh_demo_dismissed')) return;
51
+ const b = document.getElementById('demoBanner');
52
+ if (b) b.style.display = 'block';
53
+ document.getElementById('dismissDemo')?.addEventListener('click', ()=>{
54
+ localStorage.setItem('eh_demo_dismissed','1');
55
+ if (b) b.style.display = 'none';
56
  });
57
+ } catch(e) { /* non-critical */ }
58
+ })();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
+ // Small toast helper used across templates
61
+ function showToast(msg, type='info'){
62
+ const t = document.createElement('div');
63
+ t.className = 'fixed top-4 right-4 p-3 rounded shadow z-50 ' + (type==='success'?'bg-green-600':type==='error'?'bg-red-600':'bg-blue-600');
64
+ t.textContent = msg;
65
+ document.body.appendChild(t);
66
+ setTimeout(()=>t.remove(), 4000);
 
 
 
 
 
 
 
67
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  </script>
69
  </body>
70
  </html>
templates/index.html CHANGED
@@ -101,7 +101,6 @@
101
  {% endblock %}
102
 
103
  {% block scripts %}
104
- <script src="https://cdn.tailwindcss.com"></script>
105
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
106
  <script>
107
  // Core client-side JS preserved: minimal initializers and references used by the UI
@@ -192,420 +191,38 @@ async function generateStudyPlan(){
192
  else showToast('Failed to generate plan','error');
193
  }catch(e){ showToast('Plan error','error'); }
194
  }
195
- </script>
196
- {% endblock %}
197
- // Show animation
198
- setTimeout(() => toast.classList.add('show'), 100);
199
-
200
- // Auto remove
201
- setTimeout(() => {
202
- toast.classList.remove('show');
203
- setTimeout(() => toast.remove(), 300);
204
- }, duration);
205
- }
206
-
207
- function getToastTitle(type) {
208
- const titles = {
209
- 'success': '✅ Success',
210
- 'error': '❌ Error',
211
- 'warning': '⚠️ Warning',
212
- 'info': 'ℹ️ Info'
213
- };
214
- return titles[type] || 'Info';
215
- }
216
-
217
- // --- ENHANCED ERROR HANDLING ---
218
- async function handleApiRequest(url, options, errorContext = 'Request') {
219
- try {
220
- const response = await fetch(url, options);
221
-
222
- if (!response.ok) {
223
- let errorMessage = `HTTP ${response.status}`;
224
- try {
225
- const errorData = await response.json();
226
- errorMessage = errorData.error || errorMessage;
227
- } catch (e) {
228
- // Use default HTTP error
229
- }
230
- throw new Error(errorMessage);
231
- }
232
-
233
- return await response.json();
234
- } catch (error) {
235
- console.error(`${errorContext} failed:`, error);
236
-
237
- if (error.name === 'TypeError' && error.message.includes('fetch')) {
238
- showToast('Network connection error. Please check your internet connection.', 'error');
239
- } else if (error.message.includes('503')) {
240
- showToast('Service temporarily unavailable. Please check API configuration.', 'warning');
241
- } else {
242
- showToast(`${errorContext} failed: ${error.message}`, 'error');
243
- }
244
-
245
- throw error;
246
- }
247
- }
248
-
249
- // --- LOADING STATE MANAGEMENT ---
250
- function setButtonLoading(button, loading, originalText = null) {
251
- if (loading) {
252
- button.disabled = true;
253
- button.dataset.originalText = button.innerHTML;
254
- button.innerHTML = '<div class="spinner mr-2"></div> Loading...';
255
- } else {
256
- button.disabled = false;
257
- button.innerHTML = originalText || button.dataset.originalText || button.innerHTML;
258
- }
259
- }
260
-
261
- // --- INPUT VALIDATION ---
262
- function validateInput(input, errorMessage) {
263
- const value = input.value?.trim() || '';
264
- const errorElement = input.parentNode.querySelector('.error-message');
265
-
266
- if (!value) {
267
- input.classList.add('form-error');
268
- if (!errorElement) {
269
- const error = document.createElement('div');
270
- error.className = 'error-message';
271
- error.textContent = errorMessage;
272
- input.parentNode.appendChild(error);
273
- }
274
- return false;
275
- } else {
276
- input.classList.remove('form-error');
277
- if (errorElement) errorElement.remove();
278
- return true;
279
- }
280
- }
281
 
282
- // --- CARREGAMENTO DINÂMICO DE MODELOS ---
283
- async function populateModelSelector() {
284
- try {
285
- const models = await handleApiRequest('/list-models', {}, 'Loading models');
286
- modelSelector.innerHTML = '';
287
-
288
- const geminiGroup = document.createElement('optgroup');
289
- geminiGroup.label = 'Google Gemini';
290
- const groqGroup = document.createElement('optgroup');
291
- groqGroup.label = 'Groq (Ultra Fast)';
292
-
293
- models.forEach(model => {
294
- const option = document.createElement('option');
295
- option.value = model.value;
296
- option.textContent = model.name;
297
- if (model.value.startsWith('gemini:')) {
298
- geminiGroup.appendChild(option);
299
- } else if (model.value.startsWith('groq:')) {
300
- groqGroup.appendChild(option);
301
- }
302
- });
303
-
304
- modelSelector.appendChild(geminiGroup);
305
- modelSelector.appendChild(groqGroup);
306
- showToast('Models loaded successfully', 'success', 2000);
307
- } catch (error) {
308
- modelSelector.innerHTML = '<option>Error loading models</option>';
309
- }
310
- }
311
-
312
- // --- FUNÇÕES DE ÁUDIO ---
313
-
314
- // Cache para armazenar áudios gerados
315
- const audioCache = new Map();
316
-
317
- // Função para gerar uma chave de cache baseada no texto e voice
318
- function getAudioCacheKey(text, tld) {
319
- return `${text.substring(0, 100)}_${tld}_${text.length}`;
320
- }
321
-
322
- // Função para limpar cache de áudio (útil quando muda o texto significativamente)
323
- function clearAudioCache() {
324
- audioCache.forEach(blob => {
325
- if (blob && blob.url) {
326
- URL.revokeObjectURL(blob.url);
327
- }
328
- });
329
- audioCache.clear();
330
- if (currentAudioUrl) {
331
- URL.revokeObjectURL(currentAudioUrl);
332
- currentAudioUrl = null;
333
- }
334
- console.log('Audio cache cleared');
335
- }
336
-
337
- async function fetchAudioBlob(text) {
338
- const selectedTld = voiceSelector.value;
339
- const cacheKey = getAudioCacheKey(text, selectedTld);
340
-
341
- // Verificar se já temos esse áudio no cache
342
- if (audioCache.has(cacheKey)) {
343
- return audioCache.get(cacheKey);
344
- }
345
-
346
- const response = await fetch('/tts-proxy', {
347
- method: 'POST',
348
- headers: { 'Content-Type': 'application/json' },
349
- body: JSON.stringify({ text: text, tld: selectedTld })
350
- });
351
- if (!response.ok) {
352
- const err = await response.json();
353
- throw new Error(err.error || `HTTP error! status: ${response.status}`);
354
- }
355
-
356
- const blob = await response.blob();
357
-
358
- // Armazenar no cache (limitar a 10 áudios para não usar muita memória)
359
- if (audioCache.size >= 10) {
360
- const firstKey = audioCache.keys().next().value;
361
- // Revogar URL do áudio mais antigo
362
- const oldBlob = audioCache.get(firstKey);
363
- if (oldBlob && oldBlob.url) {
364
- URL.revokeObjectURL(oldBlob.url);
365
- }
366
- audioCache.delete(firstKey);
367
- }
368
-
369
- audioCache.set(cacheKey, blob);
370
- return blob;
371
- }
372
-
373
- async function playAudio(text, event) {
374
- if(event) event.stopPropagation();
375
- if (!text || !text.trim()) {
376
- showToast('No text to play', 'warning', 2000);
377
- return;
378
- }
379
-
380
- try {
381
- const audioBlob = await fetchAudioBlob(text);
382
- const audioUrl = URL.createObjectURL(audioBlob);
383
- const audio = new Audio(audioUrl);
384
-
385
- audio.onplay = () => showToast('Playing audio...', 'info', 1000);
386
- audio.onended = () => URL.revokeObjectURL(audioUrl);
387
- audio.onerror = () => showToast('Failed to play audio', 'error');
388
-
389
- await audio.play();
390
- } catch (error) {
391
- console.error('Error playing audio:', error);
392
- showToast('Failed to generate audio. Please try again.', 'error');
393
- }
394
- }
395
 
396
- // --- LÓGICA DO EDITOR ---
397
- const charCount = document.getElementById('charCount');
398
- const playText = document.getElementById('playText');
399
- const playSpinner = document.getElementById('playSpinner');
400
-
401
- let lastTextForCache = '';
402
-
403
  textEditor.addEventListener('input', () => {
404
- const len = textEditor.innerText.length;
405
- const currentText = textEditor.innerText.trim();
406
-
407
- // Atualizar contador
408
- charCount.textContent = `${len}/10000`;
409
- playButton.disabled = len === 0;
410
-
411
- // Limpar cache se o texto mudou significativamente (mais de 50 caracteres de diferença)
412
- if (Math.abs(currentText.length - lastTextForCache.length) > 50 ||
413
- (currentText.length > 100 && currentText.substring(0, 100) !== lastTextForCache.substring(0, 100))) {
414
- if (currentAudioUrl) {
415
- audioPlayer.src = '';
416
- audioPlayer.classList.add('hidden');
417
- }
418
- lastTextForCache = currentText;
419
- }
420
-
421
- // Mudança de cor baseada no limite
422
- if (len > 10000) {
423
- charCount.classList.add('text-red-500');
424
- charCount.classList.remove('text-gray-500');
425
- } else if (len > 8000) {
426
- charCount.classList.remove('text-red-500', 'text-gray-500');
427
- charCount.classList.add('text-yellow-500');
428
- } else {
429
- charCount.classList.remove('text-red-500', 'text-yellow-500');
430
- charCount.classList.add('text-gray-500');
431
- }
432
- });
433
-
434
- // Controle de seleção de texto para flashcards e áudio
435
- textEditor.addEventListener('mouseup', () => {
436
- createCardButton.disabled = !window.getSelection().toString().trim();
437
- handleTextSelection();
438
- });
439
-
440
- textEditor.addEventListener('keyup', () => {
441
- handleTextSelection();
442
  });
443
-
444
  function handleTextSelection() {
445
- const selectedText = window.getSelection().toString().trim();
446
- const playSelectionBtn = document.getElementById('playSelectionBtn');
447
-
448
- if (selectedText && selectedText.length > 0) {
449
- if (!playSelectionBtn) {
450
- // Criar botão de tocar seleção se não existir
451
- const selectionBtn = document.createElement('button');
452
- selectionBtn.id = 'playSelectionBtn';
453
- selectionBtn.className = 'btn bg-blue-500 hover:bg-blue-600 text-white ml-2';
454
- selectionBtn.innerHTML = '🎵 Play Selection';
455
- selectionBtn.onclick = () => playSelectedText();
456
-
457
- // Inserir próximo ao botão Play principal
458
- playButton.parentNode.insertBefore(selectionBtn, playButton.nextSibling);
459
- }
460
- playSelectionBtn.style.display = 'inline-block';
461
- playSelectionBtn.disabled = false;
462
- } else if (playSelectionBtn) {
463
- playSelectionBtn.style.display = 'none';
464
- }
465
- }
466
-
467
- async function playSelectedText() {
468
- const selectedText = window.getSelection().toString().trim();
469
- if (!selectedText) {
470
- showToast('Please select some text first', 'warning');
471
- return;
472
- }
473
-
474
- if (selectedText.length > 1000) {
475
- showToast('Selected text is too long. Please select less than 1000 characters.', 'warning');
476
- return;
477
- }
478
-
479
- const selectionBtn = document.getElementById('playSelectionBtn');
480
- setButtonLoading(selectionBtn, true, '🎵 Play Selection');
481
-
482
- try {
483
- await playAudio(selectedText);
484
- showToast(`Playing selected text (${selectedText.length} chars)`, 'success', 2000);
485
- } catch (error) {
486
- showToast('Failed to play selected text', 'error');
487
- } finally {
488
- setButtonLoading(selectionBtn, false, '🎵 Play Selection');
489
- }
490
- }
491
-
492
- // Variável para armazenar o URL atual do áudio
493
- let currentAudioUrl = null;
494
-
495
- async function handlePlay() {
496
- const text = textEditor.innerText.trim();
497
- if (!text) {
498
- showToast('Please enter some text first', 'warning');
499
- return;
500
- }
501
-
502
- if (text.length > 10000) { // Aumentado de 5000 para 10000
503
- showToast('Text is too long for audio generation. Please use shorter text.', 'warning');
504
- return;
505
- }
506
-
507
- const selectedTld = voiceSelector.value;
508
- const cacheKey = getAudioCacheKey(text, selectedTld);
509
-
510
- // Se já temos um áudio carregado para este texto, apenas reproduzir
511
- if (currentAudioUrl && audioPlayer.src && getAudioCacheKey(audioPlayer.dataset.lastText || '', audioPlayer.dataset.lastTld || '') === cacheKey) {
512
- try {
513
- await audioPlayer.play();
514
- showToast('Playing cached audio...', 'success', 1000);
515
- return;
516
- } catch (error) {
517
- console.log('Cached audio failed, regenerating...');
518
- }
519
- }
520
-
521
- setButtonLoading(playButton, true);
522
- playText.textContent = 'Generating...';
523
- playSpinner.classList.remove('hidden');
524
-
525
- try {
526
- const audioBlob = await fetchAudioBlob(text);
527
-
528
- // Limpar URL anterior se existir
529
- if (currentAudioUrl) {
530
- URL.revokeObjectURL(currentAudioUrl);
531
- }
532
-
533
- currentAudioUrl = URL.createObjectURL(audioBlob);
534
- audioPlayer.src = currentAudioUrl;
535
- audioPlayer.dataset.lastText = text;
536
- audioPlayer.dataset.lastTld = selectedTld;
537
- audioPlayer.classList.remove('hidden');
538
-
539
- // Remover listener anterior para evitar duplicatas
540
- audioPlayer.onplay = () => showToast('Audio ready! Playing...', 'success', 1000);
541
- audioPlayer.onended = () => {
542
- // NÃO revogar o URL aqui, manter para permitir replay
543
- showToast('Audio finished', 'info', 1000);
544
- };
545
- audioPlayer.onerror = () => showToast('Failed to play audio', 'error');
546
-
547
- await audioPlayer.play();
548
- showToast(`Audio generated successfully! (${text.length} characters)`, 'success', 2000);
549
- } catch (error) {
550
- console.error('Audio generation error:', error);
551
- showToast('Failed to generate audio. Please check your connection and try again.', 'error');
552
- } finally {
553
- setButtonLoading(playButton, false);
554
- playText.textContent = 'Listen to Text';
555
- playSpinner.classList.add('hidden');
556
- }
557
  }
558
 
559
- // --- TODAS AS OUTRAS FUNÇÕES ---
560
-
561
- // ATIVIDADES
562
- async function generateActivity() {
563
- const activityType = activityTypeSelector.value;
564
- const context = contextSelector.value;
565
- const sourceText = textEditor.innerText;
566
- generateActivityButton.disabled = true;
567
- generateActivityButton.innerHTML = '<div class="spinner mr-2"></div> Generating...';
568
- activityChatDisplay.innerHTML = '<p class="text-gray-500 italic">Generating your exercise...</p>';
569
- activityResponseArea.classList.add('hidden');
570
- let prompt = "";
571
- if (activityType === 'translate_pt_en') {
572
- prompt = `Based on the '${context}' context, create one single sentence in Portuguese for me to translate into English.`;
573
- } else if (activityType === 'email_writing') {
574
- prompt = `Create a scenario for me to write a formal email, related to the '${context}' context. Give me clear instructions on what to write.`;
575
- } else if (activityType === 'summary_writing') {
576
- if (!sourceText) {
577
- alert("Please paste text in the main editor to generate a summary activity.");
578
- activityChatDisplay.innerHTML = '<p class="text-red-500">Please paste text in the editor in card #1 first.</p>';
579
- generateActivityButton.disabled = false;
580
- generateActivityButton.textContent = 'Generate Activity';
581
- return;
582
- }
583
- prompt = `Your task is to summarize the following text in 2 or 3 sentences: "${sourceText}"`;
584
- } else if (activityType === 'professional_conversation') {
585
- prompt = `Start a professional role-playing conversation with me related to '${context}'. Ask me an open-ended question to begin.`;
586
- }
587
- try {
588
- const response = await fetch('/explain-proxy', {
589
- method: 'POST',
590
- headers: { 'Content-Type': 'application/json' },
591
- body: JSON.stringify({
592
- model: modelSelector.value,
593
- context_focus: context,
594
- custom_prompt: prompt
595
- })
596
- });
597
- if (!response.ok) throw new Error((await response.json()).error);
598
- const data = await response.json();
599
- currentActivityPrompt = data.explanation;
600
- activityChatDisplay.innerHTML = `<div class="ai-prompt"><strong>Your Task:</strong><p>${currentActivityPrompt}</p></div>`;
601
- activityResponseArea.classList.remove('hidden');
602
- } catch (error) {
603
- activityChatDisplay.innerHTML = `<p class="text-red-600 font-semibold">Failed to generate activity: ${error.message}</p>`;
604
- } finally {
605
- generateActivityButton.disabled = false;
606
- generateActivityButton.textContent = 'Generate Activity';
607
- }
608
- }
609
  async function submitActivityResponse() {
610
  const userResponse = activityInput.value.trim();
611
  if (!userResponse) {
 
101
  {% endblock %}
102
 
103
  {% block scripts %}
 
104
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
105
  <script>
106
  // Core client-side JS preserved: minimal initializers and references used by the UI
 
191
  else showToast('Failed to generate plan','error');
192
  }catch(e){ showToast('Plan error','error'); }
193
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
 
195
+ // Lightweight editor interactions (demo-safe)
196
+ const textEditor = document.getElementById('textEditor');
197
+ const charCount = document.getElementById('charCount');
198
+ const createCardButton = document.getElementById('createCardButton');
199
+ const playButton = document.getElementById('playButton');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
 
201
+ if (textEditor) {
 
 
 
 
 
 
202
  textEditor.addEventListener('input', () => {
203
+ const len = textEditor.innerText?.length || 0;
204
+ if (charCount) charCount.textContent = len;
205
+ if (playButton) playButton.disabled = len === 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
206
  });
207
+
208
  function handleTextSelection() {
209
+ const sel = (window.getSelection && window.getSelection().toString()) || '';
210
+ if (createCardButton) createCardButton.disabled = !sel.trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  }
212
 
213
+ textEditor.addEventListener('mouseup', handleTextSelection);
214
+ textEditor.addEventListener('keyup', handleTextSelection);
215
+ }
216
+
217
+ if (playButton) {
218
+ playButton.addEventListener('click', () => {
219
+ showToast('Audio generation is not enabled in demo mode.', 'info');
220
+ });
221
+ }
222
+
223
+ </script>
224
+
225
+ {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  async function submitActivityResponse() {
227
  const userResponse = activityInput.value.trim();
228
  if (!userResponse) {