ndmhung6 commited on
Commit
440b256
·
0 Parent(s):

Initial commit: PUM'S TOOLKIT - AI-powered tools collection

Browse files
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ uploads/*.wav
4
+ uploads/*.txt
5
+ uploads/*.json
6
+ .vscode/
Dockerfile ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ libgl1-mesa-glx \
7
+ libglib2.0-0 \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+
15
+ RUN mkdir -p uploads
16
+
17
+ EXPOSE 7860
18
+
19
+ CMD ["python", "-c", "from app import app; app.run(host='0.0.0.0', port=7860)"]
README.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: PUM'S TOOLKIT
3
+ emoji: 🛠️
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # PUM'S TOOLKIT 🛠️
12
+
13
+ A collection of AI-powered tools built with Flask and Gemini AI.
14
+
15
+ ## Features
16
+ - 🌐 **Translator** - Multi-language translation
17
+ - 📄 **Smart PDF** - AI-powered PDF processing
18
+ - 🎨 **Image Studio** - Image editing & background removal
19
+ - 🔊 **TTS** - Text-to-Speech conversion
app.py ADDED
@@ -0,0 +1,649 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import io
3
+ import json
4
+ import numpy as np
5
+ import cv2
6
+ from datetime import date
7
+ from flask import Flask, render_template, request, jsonify, send_file, redirect, url_for
8
+ from deep_translator import GoogleTranslator
9
+ from google import genai
10
+ from google.genai import types
11
+ from docx import Document
12
+ from PyPDF2 import PdfReader
13
+ from reportlab.pdfgen import canvas
14
+ from reportlab.lib.pagesizes import letter
15
+ from werkzeug.utils import secure_filename
16
+ from rembg import remove as rembg_remove
17
+
18
+ app = Flask(__name__)
19
+ app.config['UPLOAD_FOLDER'] = 'uploads'
20
+ app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB
21
+
22
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
23
+
24
+ GEMINI_API_KEY = "AQ.Ab8RN6JxMSl1LlebmW8kq6jjam99Z0qKf2wkwu8WjFKVEp6kGQ"
25
+ USAGE_FILE = os.path.join(app.config['UPLOAD_FOLDER'], 'gemini_usage.json')
26
+
27
+ GEMINI_MODELS = [
28
+ {"id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash", "rpd": 500},
29
+ {"id": "gemini-2.5-flash-lite", "name": "Gemini 2.5 Flash Lite", "rpd": 1000},
30
+ {"id": "gemini-2.0-flash", "name": "Gemini 2.0 Flash", "rpd": 200},
31
+ {"id": "gemini-2.0-flash-lite", "name": "Gemini 2.0 Flash Lite", "rpd": 1000},
32
+ {"id": "gemini-1.5-flash", "name": "Gemini 1.5 Flash", "rpd": 1500},
33
+ {"id": "gemini-1.5-flash-8b", "name": "Gemini 1.5 Flash 8B", "rpd": 1500},
34
+ {"id": "gemini-1.5-pro", "name": "Gemini 1.5 Pro", "rpd": 50},
35
+ ]
36
+ DEFAULT_MODEL = "gemini-2.5-flash"
37
+
38
+ # ── Usage tracking ────────────────────────────────────────────
39
+ def load_usage():
40
+ if not os.path.exists(USAGE_FILE):
41
+ return {}
42
+ with open(USAGE_FILE, 'r') as f:
43
+ data = json.load(f)
44
+ if data.get('date') != str(date.today()):
45
+ return {}
46
+ return data.get('usage', {})
47
+
48
+ def save_usage(usage_dict):
49
+ with open(USAGE_FILE, 'w') as f:
50
+ json.dump({'date': str(date.today()), 'usage': usage_dict}, f)
51
+
52
+ def increment_usage(model_id):
53
+ usage = load_usage()
54
+ usage[model_id] = usage.get(model_id, 0) + 1
55
+ save_usage(usage)
56
+
57
+ try:
58
+ client = genai.Client(api_key=GEMINI_API_KEY.strip())
59
+ except Exception as e:
60
+ print(f"Lỗi cấu hình Gemini: {e}")
61
+
62
+ # ── Translation helpers ───────────────────────────────────────
63
+ def translate_text(text, target_lang, engine, style=None, gemini_model=None):
64
+ if not text.strip():
65
+ return ""
66
+ if engine == 'google':
67
+ return GoogleTranslator(source='auto', target=target_lang).translate(text)
68
+ elif engine == 'gemini':
69
+ model_id = gemini_model if gemini_model else DEFAULT_MODEL
70
+ style_prompt = f" in a {style} style" if style and style != 'default' else ""
71
+ prompt = (f"Translate the following text to the language with code '{target_lang}'{style_prompt}. "
72
+ f"Maintain the original meaning and formatting. "
73
+ f"Detect the source language automatically. "
74
+ f"Return only the translated text:\n\n{text}")
75
+ response = client.models.generate_content(model=model_id, contents=prompt)
76
+ increment_usage(model_id)
77
+ return response.text.strip()
78
+ return text
79
+
80
+ def process_file(filepath, filename, target_lang, engine, style, gemini_model=None):
81
+ ext = filename.rsplit('.', 1)[1].lower()
82
+ output_filename = f"translated_{filename}"
83
+ output_path = os.path.join(app.config['UPLOAD_FOLDER'], output_filename)
84
+
85
+ if ext == 'txt':
86
+ with open(filepath, 'r', encoding='utf-8') as f:
87
+ text = f.read()
88
+ translated_text = translate_text(text, target_lang, engine, style, gemini_model)
89
+ with open(output_path, 'w', encoding='utf-8') as f:
90
+ f.write(translated_text)
91
+
92
+ elif ext == 'docx':
93
+ doc = Document(filepath)
94
+ new_doc = Document()
95
+ for para in doc.paragraphs:
96
+ if para.text.strip():
97
+ new_doc.add_paragraph(translate_text(para.text, target_lang, engine, style, gemini_model))
98
+ else:
99
+ new_doc.add_paragraph("")
100
+ new_doc.save(output_path)
101
+
102
+ elif ext == 'pdf':
103
+ reader = PdfReader(filepath)
104
+ text = "".join((page.extract_text() or '') + "\n" for page in reader.pages)
105
+ translated_text = translate_text(text, target_lang, engine, style, gemini_model)
106
+ c = canvas.Canvas(output_path, pagesize=letter)
107
+ width, height = letter
108
+ text_obj = c.beginText(40, height - 40)
109
+ for line in translated_text.split('\n'):
110
+ if text_obj.getY() < 40:
111
+ c.drawText(text_obj)
112
+ c.showPage()
113
+ text_obj = c.beginText(40, height - 40)
114
+ text_obj.textLine(line)
115
+ c.drawText(text_obj)
116
+ c.save()
117
+
118
+ return output_filename
119
+
120
+ def _err_msg(err):
121
+ if '429' in err or 'quota' in err.lower():
122
+ return 'Gemini API đã h���t quota. Vui lòng thử lại sau hoặc chuyển sang Google Translate.'
123
+ if 'API_KEY' in err or 'api key' in err.lower():
124
+ return 'API key không hợp lệ. Vui lòng kiểm tra cấu hình.'
125
+ return err
126
+
127
+ # ── Routes: General ──────────────────────────────────────────
128
+ @app.route('/')
129
+ def index():
130
+ return render_template('index.html')
131
+
132
+ @app.route('/download/<filename>')
133
+ def download(filename):
134
+ return send_file(os.path.join(app.config['UPLOAD_FOLDER'], filename), as_attachment=True)
135
+
136
+ # ── Routes: API ───────────────────────────────────────────────
137
+ @app.route('/api/gemini-models')
138
+ def gemini_models():
139
+ usage = load_usage()
140
+ result = []
141
+ for m in GEMINI_MODELS:
142
+ used = usage.get(m['id'], 0)
143
+ result.append({**m, 'used': used, 'remaining': max(0, m['rpd'] - used)})
144
+ return jsonify(result)
145
+
146
+ @app.route('/api/gemini-quota')
147
+ def gemini_quota():
148
+ return jsonify(load_usage())
149
+
150
+ @app.route('/api/languages')
151
+ def languages():
152
+ langs = [
153
+ {"code": "auto", "name": "Tự động phát hiện", "source_only": True},
154
+ {"code": "af", "name": "Afrikaans"},
155
+ {"code": "sq", "name": "Albanian"},
156
+ {"code": "am", "name": "Amharic"},
157
+ {"code": "ar", "name": "Arabic (العربية)"},
158
+ {"code": "hy", "name": "Armenian"},
159
+ {"code": "az", "name": "Azerbaijani"},
160
+ {"code": "eu", "name": "Basque"},
161
+ {"code": "be", "name": "Belarusian"},
162
+ {"code": "bn", "name": "Bengali (বাংলা)"},
163
+ {"code": "bs", "name": "Bosnian"},
164
+ {"code": "bg", "name": "Bulgarian"},
165
+ {"code": "ca", "name": "Catalan"},
166
+ {"code": "ceb", "name": "Cebuano"},
167
+ {"code": "zh-CN", "name": "Chinese Simplified (中文简体)"},
168
+ {"code": "zh-TW", "name": "Chinese Traditional (中文繁體)"},
169
+ {"code": "co", "name": "Corsican"},
170
+ {"code": "hr", "name": "Croatian"},
171
+ {"code": "cs", "name": "Czech"},
172
+ {"code": "da", "name": "Danish"},
173
+ {"code": "nl", "name": "Dutch"},
174
+ {"code": "en", "name": "English"},
175
+ {"code": "eo", "name": "Esperanto"},
176
+ {"code": "et", "name": "Estonian"},
177
+ {"code": "fi", "name": "Finnish"},
178
+ {"code": "fr", "name": "French"},
179
+ {"code": "fy", "name": "Frisian"},
180
+ {"code": "gl", "name": "Galician"},
181
+ {"code": "ka", "name": "Georgian"},
182
+ {"code": "de", "name": "German"},
183
+ {"code": "el", "name": "Greek"},
184
+ {"code": "gu", "name": "Gujarati"},
185
+ {"code": "ht", "name": "Haitian Creole"},
186
+ {"code": "ha", "name": "Hausa"},
187
+ {"code": "haw", "name": "Hawaiian"},
188
+ {"code": "he", "name": "Hebrew"},
189
+ {"code": "hi", "name": "Hindi (हिन्दी)"},
190
+ {"code": "hmn", "name": "Hmong"},
191
+ {"code": "hu", "name": "Hungarian"},
192
+ {"code": "is", "name": "Icelandic"},
193
+ {"code": "ig", "name": "Igbo"},
194
+ {"code": "id", "name": "Indonesian"},
195
+ {"code": "ga", "name": "Irish"},
196
+ {"code": "it", "name": "Italian"},
197
+ {"code": "ja", "name": "Japanese (日本語)"},
198
+ {"code": "jv", "name": "Javanese"},
199
+ {"code": "kn", "name": "Kannada"},
200
+ {"code": "kk", "name": "Kazakh"},
201
+ {"code": "km", "name": "Khmer"},
202
+ {"code": "rw", "name": "Kinyarwanda"},
203
+ {"code": "ko", "name": "Korean (한국어)"},
204
+ {"code": "ku", "name": "Kurdish"},
205
+ {"code": "ky", "name": "Kyrgyz"},
206
+ {"code": "lo", "name": "Lao"},
207
+ {"code": "la", "name": "Latin"},
208
+ {"code": "lv", "name": "Latvian"},
209
+ {"code": "lt", "name": "Lithuanian"},
210
+ {"code": "lb", "name": "Luxembourgish"},
211
+ {"code": "mk", "name": "Macedonian"},
212
+ {"code": "mg", "name": "Malagasy"},
213
+ {"code": "ms", "name": "Malay"},
214
+ {"code": "ml", "name": "Malayalam"},
215
+ {"code": "mt", "name": "Maltese"},
216
+ {"code": "mi", "name": "Maori"},
217
+ {"code": "mr", "name": "Marathi"},
218
+ {"code": "mn", "name": "Mongolian"},
219
+ {"code": "my", "name": "Myanmar (Burmese)"},
220
+ {"code": "ne", "name": "Nepali"},
221
+ {"code": "no", "name": "Norwegian"},
222
+ {"code": "ny", "name": "Nyanja (Chichewa)"},
223
+ {"code": "or", "name": "Odia (Oriya)"},
224
+ {"code": "ps", "name": "Pashto"},
225
+ {"code": "fa", "name": "Persian (فارسی)"},
226
+ {"code": "pl", "name": "Polish"},
227
+ {"code": "pt", "name": "Portuguese"},
228
+ {"code": "pa", "name": "Punjabi"},
229
+ {"code": "ro", "name": "Romanian"},
230
+ {"code": "ru", "name": "Russian (Русский)"},
231
+ {"code": "sm", "name": "Samoan"},
232
+ {"code": "gd", "name": "Scots Gaelic"},
233
+ {"code": "sr", "name": "Serbian"},
234
+ {"code": "st", "name": "Sesotho"},
235
+ {"code": "sn", "name": "Shona"},
236
+ {"code": "sd", "name": "Sindhi"},
237
+ {"code": "si", "name": "Sinhala"},
238
+ {"code": "sk", "name": "Slovak"},
239
+ {"code": "sl", "name": "Slovenian"},
240
+ {"code": "so", "name": "Somali"},
241
+ {"code": "es", "name": "Spanish"},
242
+ {"code": "su", "name": "Sundanese"},
243
+ {"code": "sw", "name": "Swahili"},
244
+ {"code": "sv", "name": "Swedish"},
245
+ {"code": "tl", "name": "Tagalog (Filipino)"},
246
+ {"code": "tg", "name": "Tajik"},
247
+ {"code": "ta", "name": "Tamil"},
248
+ {"code": "tt", "name": "Tatar"},
249
+ {"code": "te", "name": "Telugu"},
250
+ {"code": "th", "name": "Thai (ไทย)"},
251
+ {"code": "tr", "name": "Turkish"},
252
+ {"code": "tk", "name": "Turkmen"},
253
+ {"code": "uk", "name": "Ukrainian"},
254
+ {"code": "ur", "name": "Urdu"},
255
+ {"code": "ug", "name": "Uyghur"},
256
+ {"code": "uz", "name": "Uzbek"},
257
+ {"code": "vi", "name": "Vietnamese (Tiếng Việt)"},
258
+ {"code": "cy", "name": "Welsh"},
259
+ {"code": "xh", "name": "Xhosa"},
260
+ {"code": "yi", "name": "Yiddish"},
261
+ {"code": "yo", "name": "Yoruba"},
262
+ {"code": "zu", "name": "Zulu"},
263
+ ]
264
+ return jsonify(langs)
265
+
266
+ # ── Routes: Translator ────────────────────────────────────────
267
+ @app.route('/translator')
268
+ def translator_tool():
269
+ return redirect(url_for('translator_text'))
270
+
271
+ @app.route('/translator/text')
272
+ def translator_text():
273
+ return render_template('tools/translator/index.html', active_tab='text')
274
+
275
+ @app.route('/translator/file')
276
+ def translator_file():
277
+ return render_template('tools/translator/index.html', active_tab='file')
278
+
279
+ @app.route('/translator/image')
280
+ def translator_image():
281
+ return render_template('tools/translator/image.html')
282
+
283
+
284
+ def translate():
285
+ if 'file' in request.files and request.files['file'].filename != '':
286
+ file = request.files['file']
287
+ target_lang = request.form.get('target_lang', 'vi')
288
+ engine = request.form.get('engine', 'google')
289
+ style = request.form.get('style', 'default')
290
+ gemini_model = request.form.get('gemini_model', DEFAULT_MODEL)
291
+ filename = secure_filename(file.filename)
292
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
293
+ file.save(filepath)
294
+ try:
295
+ output_file = process_file(filepath, filename, target_lang, engine, style, gemini_model)
296
+ return jsonify({'download_url': f'/download/{output_file}'})
297
+ except Exception as e:
298
+ return jsonify({'error': _err_msg(str(e))}), 500
299
+
300
+ data = request.json
301
+ if not data or not data.get('text'):
302
+ return jsonify({'error': 'No text provided'}), 400
303
+ try:
304
+ translated = translate_text(
305
+ data['text'], data.get('target_lang', 'vi'),
306
+ data.get('engine', 'google'), data.get('style', 'default'),
307
+ data.get('gemini_model', DEFAULT_MODEL)
308
+ )
309
+ return jsonify({'translated_text': translated})
310
+ except Exception as e:
311
+ return jsonify({'error': _err_msg(str(e))}), 500
312
+
313
+ @app.route('/preview-file', methods=['POST'])
314
+ def preview_file():
315
+ if 'file' not in request.files or request.files['file'].filename == '':
316
+ return jsonify({'error': 'Không có file'}), 400
317
+ file = request.files['file']
318
+ filename = secure_filename(file.filename)
319
+ ext = filename.rsplit('.', 1)[-1].lower()
320
+ preview_chars = 1000
321
+ try:
322
+ if ext == 'txt':
323
+ text = file.read().decode('utf-8', errors='ignore')
324
+ elif ext == 'docx':
325
+ doc = Document(file)
326
+ text = '\n'.join(p.text for p in doc.paragraphs if p.text.strip())
327
+ elif ext == 'pdf':
328
+ reader = PdfReader(file)
329
+ text = ''
330
+ for page in reader.pages:
331
+ text += (page.extract_text() or '') + '\n'
332
+ if len(text) >= preview_chars:
333
+ break
334
+ else:
335
+ return jsonify({'error': 'Định dạng không hỗ trợ'}), 400
336
+ preview = text[:preview_chars]
337
+ return jsonify({'preview': preview, 'truncated': len(text) > preview_chars, 'total_chars': len(text)})
338
+ except Exception as e:
339
+ return jsonify({'error': str(e)}), 500
340
+
341
+ # ── Routes: Image Studio ──────────────────────────────────────
342
+ @app.route('/image-studio')
343
+ def image_studio():
344
+ return redirect(url_for('image_studio_remove_bg'))
345
+
346
+ @app.route('/image-studio/remove-bg')
347
+ def image_studio_remove_bg():
348
+ return render_template('tools/image_studio/index.html', active_tool='remove-bg')
349
+
350
+ @app.route('/image-studio/upscale')
351
+ def image_studio_upscale():
352
+ return render_template('tools/image_studio/index.html', active_tool='upscale')
353
+
354
+ @app.route('/image/remove-bg', methods=['POST'])
355
+ def image_remove_bg():
356
+ if 'image' not in request.files or request.files['image'].filename == '':
357
+ return jsonify({'error': 'Không có ảnh'}), 400
358
+ file = request.files['image']
359
+ filename = secure_filename(file.filename)
360
+ ext = filename.rsplit('.', 1)[-1].lower()
361
+ if ext not in ['jpg', 'jpeg', 'png', 'webp']:
362
+ return jsonify({'error': 'Định dạng không hỗ trợ. Dùng JPG, PNG hoặc WEBP.'}), 400
363
+ try:
364
+ img_bytes = file.read()
365
+ output_bytes = rembg_remove(img_bytes)
366
+ out_filename = f"nobg_{os.path.splitext(filename)[0]}.png"
367
+ out_path = os.path.join(app.config['UPLOAD_FOLDER'], out_filename)
368
+ with open(out_path, 'wb') as f:
369
+ f.write(output_bytes)
370
+ return jsonify({'result_url': f'/download/{out_filename}', 'filename': out_filename})
371
+ except Exception as e:
372
+ return jsonify({'error': str(e)}), 500
373
+
374
+ @app.route('/image/upscale', methods=['POST'])
375
+ def image_upscale():
376
+ if 'image' not in request.files or request.files['image'].filename == '':
377
+ return jsonify({'error': 'Không có ảnh'}), 400
378
+ file = request.files['image']
379
+ scale = int(request.form.get('scale', 2))
380
+ if scale not in [2, 3, 4]:
381
+ scale = 2
382
+ filename = secure_filename(file.filename)
383
+ ext = filename.rsplit('.', 1)[-1].lower()
384
+ if ext not in ['jpg', 'jpeg', 'png', 'webp']:
385
+ return jsonify({'error': 'Định dạng không hỗ trợ. Dùng JPG, PNG hoặc WEBP.'}), 400
386
+ try:
387
+ img_bytes = np.frombuffer(file.read(), np.uint8)
388
+ img = cv2.imdecode(img_bytes, cv2.IMREAD_UNCHANGED)
389
+ h, w = img.shape[:2]
390
+ new_w, new_h = w * scale, h * scale
391
+ upscaled = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
392
+ out_filename = f"upscaled_{scale}x_{filename}"
393
+ out_path = os.path.join(app.config['UPLOAD_FOLDER'], out_filename)
394
+ cv2.imwrite(out_path, upscaled)
395
+ return jsonify({
396
+ 'result_url': f'/download/{out_filename}',
397
+ 'filename': out_filename,
398
+ 'original_size': f"{w}x{h}",
399
+ 'new_size': f"{new_w}x{new_h}"
400
+ })
401
+ except Exception as e:
402
+ return jsonify({'error': str(e)}), 500
403
+
404
+ @app.route('/translate-image', methods=['POST'])
405
+ def translate_image():
406
+ if 'image' not in request.files or request.files['image'].filename == '':
407
+ return jsonify({'error': 'Không có ảnh'}), 400
408
+ file = request.files['image']
409
+ filename = secure_filename(file.filename)
410
+ ext = filename.rsplit('.', 1)[-1].lower()
411
+ if ext not in ['jpg', 'jpeg', 'png', 'webp', 'gif']:
412
+ return jsonify({'error': 'Định dạng không hỗ trợ. Dùng JPG, PNG, WEBP hoặc GIF.'}), 400
413
+
414
+ target_lang = request.form.get('target_lang', 'vi')
415
+ model_id = request.form.get('gemini_model', DEFAULT_MODEL)
416
+ mode = request.form.get('mode', 'translate') # translate | ocr_only
417
+
418
+ try:
419
+ import base64
420
+ img_bytes = file.read()
421
+ img_b64 = base64.b64encode(img_bytes).decode('utf-8')
422
+ mime_map = {'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'png': 'image/png',
423
+ 'webp': 'image/webp', 'gif': 'image/gif'}
424
+ mime_type = mime_map.get(ext, 'image/jpeg')
425
+
426
+ from google.genai import types as genai_types
427
+
428
+ if mode == 'ocr_only':
429
+ prompt = "Hãy đọc và trích xuất toàn bộ văn bản trong ảnh này. Chỉ trả về văn bản, không giải thích thêm."
430
+ else:
431
+ prompt = (f"Hãy thực hiện 2 bước:\n"
432
+ f"1. Đọc toàn bộ văn bản trong ảnh\n"
433
+ f"2. Dịch văn bản đó sang ngôn ngữ có mã '{target_lang}'\n\n"
434
+ f"Trả về theo định dạng:\n"
435
+ f"**Văn bản gốc:**\n[nội dung đọc được]\n\n"
436
+ f"**Bản dịch:**\n[bản dịch]")
437
+
438
+ response = client.models.generate_content(
439
+ model=model_id,
440
+ contents=[
441
+ genai_types.Part.from_bytes(data=img_bytes, mime_type=mime_type),
442
+ prompt
443
+ ]
444
+ )
445
+ increment_usage(model_id)
446
+ return jsonify({'result': response.text.strip()})
447
+ except Exception as e:
448
+ return jsonify({'error': _err_msg(str(e))}), 500
449
+
450
+ @app.route('/tts')
451
+ def tts_index():
452
+ return redirect(url_for('tts_vieneu'))
453
+
454
+ @app.route('/tts/vieneu')
455
+ def tts_vieneu():
456
+ return render_template('tools/tts/vieneu.html')
457
+
458
+ @app.route('/tts/gtts')
459
+ def tts_gtts():
460
+ return render_template('tools/tts/gtts.html')
461
+
462
+ @app.route('/tts/edge')
463
+ def tts_edge():
464
+ return render_template('tools/tts/edge.html')
465
+
466
+
467
+ def tts_voices():
468
+ try:
469
+ from vieneu import Vieneu
470
+ tts = Vieneu()
471
+ voices = tts.list_preset_voices()
472
+ return jsonify([{'label': label, 'id': vid} for label, vid in voices])
473
+ except Exception as e:
474
+ return jsonify({'error': str(e)}), 500
475
+
476
+ @app.route('/api/edge-voices')
477
+ def edge_voices():
478
+ import asyncio, edge_tts
479
+ try:
480
+ voices = asyncio.run(edge_tts.list_voices())
481
+ result = []
482
+ for v in voices:
483
+ result.append({
484
+ 'name': v['ShortName'],
485
+ 'display': v.get('FriendlyName', v['ShortName']),
486
+ 'locale': v['Locale'],
487
+ 'gender': v['Gender'],
488
+ 'lang': v['Locale'].split('-')[0]
489
+ })
490
+ return jsonify(result)
491
+ except Exception as e:
492
+ return jsonify({'error': str(e)}), 500
493
+
494
+ @app.route('/tts/edge/generate', methods=['POST'])
495
+ def tts_edge_generate():
496
+ import asyncio, edge_tts
497
+ text = request.form.get('text', '').strip()
498
+ voice = request.form.get('voice', 'en-US-JennyNeural')
499
+ rate = request.form.get('rate', '+0%')
500
+ pitch = request.form.get('pitch', '+0Hz')
501
+
502
+ if not text:
503
+ return jsonify({'error': 'Vui lòng nhập văn bản'}), 400
504
+ if len(text) > 10000:
505
+ return jsonify({'error': 'Văn bản quá dài (tối đa 10.000 ký tự)'}), 400
506
+
507
+ out_filename = f"tts_edge_{os.urandom(4).hex()}.mp3"
508
+ out_path = os.path.join(app.config['UPLOAD_FOLDER'], out_filename)
509
+
510
+ async def _generate():
511
+ communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch)
512
+ await communicate.save(out_path)
513
+
514
+ try:
515
+ asyncio.run(_generate())
516
+ return jsonify({'audio_url': f'/download/{out_filename}', 'filename': out_filename})
517
+ except Exception as e:
518
+ return jsonify({'error': str(e)}), 500
519
+
520
+ @app.route('/api/gtts-langs')
521
+ def gtts_langs():
522
+ return jsonify([
523
+ {"code": "vi", "name": "Tiếng Việt"},
524
+ {"code": "en", "name": "English"},
525
+ {"code": "en-us", "name": "English (US)"},
526
+ {"code": "en-gb", "name": "English (UK)"},
527
+ {"code": "en-au", "name": "English (AU)"},
528
+ {"code": "ja", "name": "日本語 (Nhật)"},
529
+ {"code": "ko", "name": "한국어 (Hàn)"},
530
+ {"code": "zh-CN", "name": "中文简体 (Trung giản)"},
531
+ {"code": "zh-TW", "name": "中文繁體 (Trung phồn)"},
532
+ {"code": "fr", "name": "Français (Pháp)"},
533
+ {"code": "de", "name": "Deutsch (Đức)"},
534
+ {"code": "es", "name": "Español (Tây Ban Nha)"},
535
+ {"code": "it", "name": "Italiano (Ý)"},
536
+ {"code": "pt", "name": "Português (Bồ Đào Nha)"},
537
+ {"code": "ru", "name": "Русский (Nga)"},
538
+ {"code": "ar", "name": "العربية (Ả Rập)"},
539
+ {"code": "hi", "name": "हिन्दी (Hindi)"},
540
+ {"code": "th", "name": "ภาษาไทย (Thái)"},
541
+ {"code": "id", "name": "Bahasa Indonesia"},
542
+ {"code": "ms", "name": "Bahasa Melayu (Mã Lai)"},
543
+ {"code": "nl", "name": "Nederlands (Hà Lan)"},
544
+ {"code": "pl", "name": "Polski (Ba Lan)"},
545
+ {"code": "sv", "name": "Svenska (Thụy Điển)"},
546
+ {"code": "tr", "name": "Türkçe (Thổ Nhĩ Kỳ)"},
547
+ {"code": "uk", "name": "Українська (Ukraine)"},
548
+ ])
549
+
550
+ @app.route('/tts/generate', methods=['POST'])
551
+ def tts_generate():
552
+ text = request.form.get('text', '').strip()
553
+ voice_id = request.form.get('voice', 'Ngọc Lan')
554
+ ref_audio = request.files.get('ref_audio')
555
+ engine = request.form.get('engine', 'vieneu') # vieneu | gtts
556
+ gtts_lang = request.form.get('gtts_lang', 'vi')
557
+ gtts_slow = request.form.get('gtts_slow', 'false') == 'true'
558
+
559
+ if not text:
560
+ return jsonify({'error': 'Vui lòng nhập văn bản'}), 400
561
+
562
+ out_filename = f"tts_{os.urandom(4).hex()}.mp3"
563
+ out_path = os.path.join(app.config['UPLOAD_FOLDER'], out_filename)
564
+
565
+ try:
566
+ if engine == 'gtts':
567
+ if len(text) > 5000:
568
+ return jsonify({'error': 'Văn bản quá dài (tối đa 5000 ký tự với gTTS)'}), 400
569
+ from gtts import gTTS
570
+ tts_obj = gTTS(text=text, lang=gtts_lang, slow=gtts_slow)
571
+ tts_obj.save(out_path)
572
+ return jsonify({'audio_url': f'/download/{out_filename}', 'filename': out_filename})
573
+
574
+ # VieNeu engine
575
+ if len(text) > 3000:
576
+ return jsonify({'error': 'Văn bản quá dài (tối đa 3000 ký tự với VieNeu)'}), 400
577
+ from vieneu import Vieneu
578
+ tts_obj = Vieneu()
579
+ out_filename = f"tts_{os.urandom(4).hex()}.wav"
580
+ out_path = os.path.join(app.config['UPLOAD_FOLDER'], out_filename)
581
+
582
+ if ref_audio and ref_audio.filename:
583
+ ref_filename = secure_filename(ref_audio.filename)
584
+ ref_path = os.path.join(app.config['UPLOAD_FOLDER'], f"ref_{ref_filename}")
585
+ ref_audio.save(ref_path)
586
+ audio = tts_obj.infer(text=text, ref_audio=ref_path)
587
+ else:
588
+ audio = tts_obj.infer(text=text, voice=voice_id)
589
+
590
+ tts_obj.save(audio, out_path)
591
+ return jsonify({'audio_url': f'/download/{out_filename}', 'filename': out_filename})
592
+
593
+ except Exception as e:
594
+ return jsonify({'error': str(e)}), 500
595
+ def extract_pdf_text(file):
596
+ reader = PdfReader(file)
597
+ return "".join((page.extract_text() or '') + "\n" for page in reader.pages).strip()
598
+
599
+ @app.route('/pdf')
600
+ def pdf_index():
601
+ return redirect(url_for('pdf_summarize'))
602
+
603
+ @app.route('/pdf/summarize')
604
+ def pdf_summarize():
605
+ return render_template('tools/smart_pdf/index.html', active_tool='summarize')
606
+
607
+ @app.route('/pdf/extract')
608
+ def pdf_extract():
609
+ return render_template('tools/smart_pdf/index.html', active_tool='extract')
610
+
611
+ @app.route('/pdf/process', methods=['POST'])
612
+ def pdf_process():
613
+ if 'file' not in request.files or request.files['file'].filename == '':
614
+ return jsonify({'error': 'Không có file PDF'}), 400
615
+ file = request.files['file']
616
+ filename = secure_filename(file.filename)
617
+ if not filename.lower().endswith('.pdf'):
618
+ return jsonify({'error': 'Chỉ hỗ trợ file PDF'}), 400
619
+ tool = request.form.get('tool', 'summarize')
620
+ model_id = request.form.get('gemini_model', DEFAULT_MODEL)
621
+ lang = request.form.get('lang', 'vi')
622
+ extract_type = request.form.get('extract_type', 'table')
623
+ try:
624
+ text = extract_pdf_text(file)
625
+ if not text:
626
+ return jsonify({'error': 'Không đọc được nội dung PDF. File có thể bị scan (ảnh).'}), 400
627
+ if tool == 'summarize':
628
+ prompt = (f"Hãy tóm tắt tài liệu PDF sau bằng ngôn ngữ có mã '{lang}'. "
629
+ f"Tóm tắt cần:\n1. Ý chính tổng quan (2-3 câu)\n2. Các điểm quan trọng (bullet points)\n"
630
+ f"3. Kết luận/khuyến nghị (nếu có)\n\nNội dung tài liệu:\n{text[:15000]}")
631
+ else:
632
+ type_map = {
633
+ 'table': 'bảng dữ liệu (tables) dưới dạng Markdown',
634
+ 'key_value': 'các cặp thông tin quan trọng (key-value) như tên, ngày, số liệu',
635
+ 'entity': 'các thực thể: tên người, tổ chức, địa điểm, ngày tháng, số tiền',
636
+ 'custom': request.form.get('custom_prompt', 'thông tin quan trọng')
637
+ }
638
+ extract_desc = type_map.get(extract_type, type_map['key_value'])
639
+ prompt = (f"Từ tài liệu PDF sau, hãy trích xuất {extract_desc}. "
640
+ f"Trả lời bằng ngôn ngữ có mã '{lang}'. Định dạng output rõ ràng, dễ đọc.\n\n"
641
+ f"Nội dung tài liệu:\n{text[:15000]}")
642
+ response = client.models.generate_content(model=model_id, contents=prompt)
643
+ increment_usage(model_id)
644
+ return jsonify({'result': response.text.strip(), 'total_chars': len(text), 'truncated': len(text) > 15000})
645
+ except Exception as e:
646
+ return jsonify({'error': _err_msg(str(e))}), 500
647
+
648
+ if __name__ == '__main__':
649
+ app.run(debug=True, port=5000)
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ deep-translator
3
+ google-genai
4
+ python-docx
5
+ PyPDF2
6
+ reportlab
7
+ opencv-python-headless
8
+ numpy
9
+ rembg
10
+ werkzeug
static/logo.png ADDED
static/logo2.png ADDED
static/theme.css ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ==============================================
2
+ PUMS TOOLKIT — DESIGN TOKENS
3
+ Sửa các giá trị ở đây để thay đổi toàn bộ giao diện
4
+ ============================================== */
5
+
6
+ :root {
7
+ /* --- Màu chính --- */
8
+ --color-primary: #4361ee;
9
+ --color-primary-dark: #3730a3;
10
+ --color-primary-light: #f0f3ff;
11
+ --color-primary-hover: #eef2ff;
12
+
13
+ /* --- Màu nền --- */
14
+ --color-bg-page: #f5f7fa;
15
+ --color-bg-sidebar: #ffffff;
16
+ --color-bg-card: #ffffff;
17
+ --color-bg-input: #f8fafc;
18
+
19
+ /* --- Màu chữ --- */
20
+ --color-text-heading: #1e293b;
21
+ --color-text-body: #334155;
22
+ --color-text-muted: #64748b;
23
+ --color-text-disabled: #cbd5e1;
24
+
25
+ /* --- Màu border --- */
26
+ --color-border: #e8ecf0;
27
+ --color-border-light: #f0f2f5;
28
+ --color-border-input: #e2e8f0;
29
+
30
+ /* --- Màu trạng thái nav active --- */
31
+ --color-nav-active-bg: #f0f3ff;
32
+ --color-nav-active-txt: #4361ee;
33
+
34
+ /* --- Màu label section "sắp có" --- */
35
+ --color-label-muted: #94a3b8;
36
+
37
+ /* --- Typography --- */
38
+ --font-family: 'Inter', sans-serif;
39
+ --font-size-base: 0.875rem;
40
+ --font-size-sm: 0.75rem;
41
+ --font-size-xs: 0.68rem;
42
+ --font-weight-normal: 400;
43
+ --font-weight-semi: 600;
44
+ --font-weight-bold: 800;
45
+
46
+ /* --- Spacing --- */
47
+ --spacing-xs: 0.25rem;
48
+ --spacing-sm: 0.5rem;
49
+ --spacing-md: 1rem;
50
+ --spacing-lg: 1.5rem;
51
+ --spacing-xl: 2rem;
52
+
53
+ /* --- Border radius --- */
54
+ --radius-sm: 6px;
55
+ --radius-md: 10px;
56
+ --radius-lg: 16px;
57
+ --radius-xl: 20px;
58
+
59
+ /* --- Shadow --- */
60
+ --shadow-card: 0 1px 4px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.04);
61
+ --shadow-btn: 0 4px 12px rgba(67, 97, 238, 0.25);
62
+
63
+ /* --- Kích thước sidebar --- */
64
+ --sidebar-width: 260px;
65
+ }
66
+
67
+ /* ==============================================
68
+ BASE STYLES
69
+ ============================================== */
70
+
71
+ body {
72
+ background-color: var(--color-bg-page);
73
+ font-family: var(--font-family);
74
+ font-size: var(--font-size-base);
75
+ color: var(--color-text-body);
76
+ display: flex;
77
+ min-height: 100vh;
78
+ }
79
+
80
+ /* ==============================================
81
+ SIDEBAR
82
+ ============================================== */
83
+
84
+ .sidebar {
85
+ width: var(--sidebar-width);
86
+ background: var(--color-bg-sidebar);
87
+ border-right: 1px solid var(--color-border);
88
+ padding: var(--spacing-lg) calc(var(--spacing-lg) * 0.8);
89
+ flex-shrink: 0;
90
+ }
91
+
92
+ /* --- Brand / Logo --- */
93
+ .brand {
94
+ display: flex;
95
+ align-items: center;
96
+ gap: 10px;
97
+ margin-bottom: var(--spacing-xl);
98
+ padding-bottom: var(--spacing-lg);
99
+ border-bottom: 1px solid var(--color-border-light);
100
+ }
101
+
102
+ .brand img {
103
+ width: 36px;
104
+ height: 36px;
105
+ object-fit: contain;
106
+ mix-blend-mode: multiply;
107
+ }
108
+
109
+ .brand-name {
110
+ font-weight: var(--font-weight-bold);
111
+ font-size: 0.95rem;
112
+ color: var(--color-text-heading);
113
+ letter-spacing: 0.04em;
114
+ }
115
+
116
+ /* --- Navigation --- */
117
+ .nav-link {
118
+ padding: 0.65rem 0.9rem;
119
+ border-radius: var(--radius-md);
120
+ color: var(--color-text-muted);
121
+ font-weight: var(--font-weight-semi);
122
+ font-size: var(--font-size-base);
123
+ margin-bottom: 0.2rem;
124
+ transition: background 0.15s, color 0.15s;
125
+ display: flex;
126
+ align-items: center;
127
+ gap: 10px;
128
+ }
129
+
130
+ .nav-link:hover {
131
+ background: var(--color-bg-input);
132
+ color: var(--color-text-heading);
133
+ }
134
+
135
+ .nav-link.active {
136
+ background: var(--color-nav-active-bg);
137
+ color: var(--color-nav-active-txt);
138
+ }
139
+
140
+ .nav-link i {
141
+ width: 16px;
142
+ font-size: 0.85rem;
143
+ }
144
+
145
+ /* --- Sub nav (children of a nav item) --- */
146
+ .nav-sub {
147
+ padding-left: 1.5rem;
148
+ border-left: 2px solid var(--color-border-light);
149
+ margin-left: 1.2rem;
150
+ margin-bottom: 0.2rem;
151
+ }
152
+
153
+ .nav-link-sub {
154
+ font-size: 0.82rem;
155
+ padding: 0.45rem 0.75rem;
156
+ color: var(--color-text-muted);
157
+ }
158
+
159
+ .nav-link-sub.active {
160
+ background: var(--color-nav-active-bg);
161
+ color: var(--color-nav-active-txt);
162
+ font-weight: var(--font-weight-semi);
163
+ }
164
+
165
+ /* --- Collapsible nav item --- */
166
+ .nav-link-collapsible {
167
+ justify-content: space-between;
168
+ }
169
+
170
+ .nav-chevron {
171
+ font-size: 0.7rem;
172
+ transition: transform 0.2s ease;
173
+ color: var(--color-text-disabled);
174
+ }
175
+
176
+ .nav-chevron.collapsed {
177
+ transform: rotate(-90deg);
178
+ }
179
+
180
+ .nav-section-label {
181
+ font-size: var(--font-size-xs);
182
+ text-transform: uppercase;
183
+ color: var(--color-label-muted);
184
+ font-weight: var(--font-weight-semi);
185
+ letter-spacing: 0.08em;
186
+ padding: var(--spacing-md) 0.9rem 0.4rem;
187
+ }
188
+
189
+ .nav-link.disabled {
190
+ color: var(--color-text-disabled);
191
+ cursor: not-allowed;
192
+ pointer-events: none;
193
+ }
194
+
195
+ /* ==============================================
196
+ MAIN CONTENT
197
+ ============================================== */
198
+
199
+ .main-content {
200
+ flex-grow: 1;
201
+ padding: var(--spacing-xl);
202
+ max-width: 1200px;
203
+ margin: 0 auto;
204
+ }
205
+
206
+ /* ==============================================
207
+ CARD
208
+ ============================================== */
209
+
210
+ .card-custom {
211
+ border: none;
212
+ border-radius: var(--radius-lg);
213
+ box-shadow: var(--shadow-card);
214
+ background: var(--color-bg-card);
215
+ }
216
+
217
+ /* ==============================================
218
+ BUTTONS
219
+ ============================================== */
220
+
221
+ .btn-primary-custom {
222
+ background: var(--color-primary);
223
+ border: none;
224
+ color: #ffffff;
225
+ padding: 14px 40px;
226
+ border-radius: var(--radius-md);
227
+ font-weight: var(--font-weight-bold);
228
+ font-size: var(--font-size-base);
229
+ width: 100%;
230
+ transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
231
+ cursor: pointer;
232
+ }
233
+
234
+ .btn-primary-custom:hover {
235
+ background: var(--color-primary-dark);
236
+ transform: translateY(-2px);
237
+ box-shadow: var(--shadow-btn);
238
+ }
239
+
240
+ /* ==============================================
241
+ FORM ELEMENTS
242
+ ============================================== */
243
+
244
+ .form-control,
245
+ .form-select {
246
+ border-color: var(--color-border-input);
247
+ border-radius: var(--radius-md);
248
+ font-size: var(--font-size-base);
249
+ background-color: var(--color-bg-card);
250
+ color: var(--color-text-body);
251
+ transition: border-color 0.15s, box-shadow 0.15s;
252
+ }
253
+
254
+ .form-control:focus,
255
+ .form-select:focus {
256
+ border-color: var(--color-primary);
257
+ box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.1);
258
+ outline: none;
259
+ }
260
+
261
+ .form-label {
262
+ font-weight: var(--font-weight-semi);
263
+ font-size: var(--font-size-sm);
264
+ color: var(--color-text-muted);
265
+ margin-bottom: var(--spacing-xs);
266
+ }
267
+
268
+ /* ==============================================
269
+ FILE UPLOAD
270
+ ============================================== */
271
+
272
+ .file-upload-wrapper {
273
+ border: 2px dashed var(--color-border-input);
274
+ border-radius: var(--radius-lg);
275
+ padding: 40px;
276
+ text-align: center;
277
+ background: var(--color-bg-input);
278
+ cursor: pointer;
279
+ transition: border-color 0.2s, background 0.2s;
280
+ }
281
+
282
+ .file-upload-wrapper:hover {
283
+ border-color: var(--color-primary);
284
+ background: var(--color-primary-hover);
285
+ }
286
+
287
+ /* ==============================================
288
+ NAV PILLS (tabs)
289
+ ============================================== */
290
+
291
+ .nav-pills .nav-link {
292
+ border-radius: var(--radius-md);
293
+ padding: 10px 20px;
294
+ font-weight: var(--font-weight-semi);
295
+ color: var(--color-text-muted);
296
+ border: 1px solid transparent;
297
+ transition: background 0.15s, color 0.15s, border-color 0.15s;
298
+ }
299
+
300
+ .nav-pills .nav-link:hover:not(.active) {
301
+ background: var(--color-primary-light);
302
+ color: var(--color-primary);
303
+ border-color: var(--color-primary-light);
304
+ }
305
+
306
+ .nav-pills .nav-link.active {
307
+ background-color: var(--color-primary);
308
+ color: #ffffff;
309
+ border-color: var(--color-primary);
310
+ box-shadow: 0 2px 8px rgba(67, 97, 238, 0.25);
311
+ }
312
+
313
+ /* ==============================================
314
+ SPINNER / LOADER
315
+ ============================================== */
316
+
317
+ .loader-overlay {
318
+ display: none;
319
+ position: fixed;
320
+ top: 0; left: 0;
321
+ width: 100%; height: 100%;
322
+ background: rgba(255, 255, 255, 0.85);
323
+ z-index: 1000;
324
+ justify-content: center;
325
+ align-items: center;
326
+ flex-direction: column;
327
+ }
328
+
329
+ .spinner-pro {
330
+ width: 48px;
331
+ height: 48px;
332
+ border: 4px solid var(--color-border);
333
+ border-top: 4px solid var(--color-primary);
334
+ border-radius: 50%;
335
+ animation: spin 0.9s linear infinite;
336
+ }
337
+
338
+ @keyframes spin {
339
+ 0% { transform: rotate(0deg); }
340
+ 100% { transform: rotate(360deg); }
341
+ }
templates/index.html ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Dashboard - Pum's Toolkit{% endblock %}
4
+
5
+ {% block content %}
6
+ <div class="mb-5">
7
+ <h2 class="fw-bold">Chào mừng quay trở lại! 👋</h2>
8
+ <p class="text-muted">Chọn một công cụ bên dưới để bắt đầu làm việc.</p>
9
+ </div>
10
+
11
+ <div class="row g-4">
12
+
13
+ <!-- AI Translator -->
14
+ <div class="col-md-4">
15
+ <a href="/translator" class="text-decoration-none">
16
+ <div class="card card-custom p-4 h-100 border-start border-4 border-primary">
17
+ <div class="d-flex align-items-center mb-3">
18
+ <div class="bg-primary bg-opacity-10 p-3 rounded-3 text-primary me-3">
19
+ <i class="fas fa-language fa-2x"></i>
20
+ </div>
21
+ <h5 class="mb-0 fw-bold text-dark">AI Translator Pro</h5>
22
+ </div>
23
+ <p class="text-muted small mb-0">Dịch văn bản và tài liệu đa định dạng với Google & Gemini AI mạnh mẽ.</p>
24
+ </div>
25
+ </a>
26
+ </div>
27
+
28
+ <!-- Text to Speech -->
29
+ <div class="col-md-4">
30
+ <a href="/tts" class="text-decoration-none">
31
+ <div class="card card-custom p-4 h-100 border-start border-4" style="border-color: #8b5cf6 !important">
32
+ <div class="d-flex align-items-center mb-3">
33
+ <div class="p-3 rounded-3 me-3" style="background: #8b5cf620; color: #8b5cf6">
34
+ <i class="fas fa-microphone-alt fa-2x"></i>
35
+ </div>
36
+ <h5 class="mb-0 fw-bold text-dark">Text to Speech</h5>
37
+ </div>
38
+ <p class="text-muted small mb-0">Chuyển văn bản thành giọng nói tự nhiên — VieNeu, Google TTS, Edge TTS (400+ giọng).</p>
39
+ </div>
40
+ </a>
41
+ </div>
42
+
43
+ <!-- Smart PDF -->
44
+ <div class="col-md-4">
45
+ <a href="/pdf/summarize" class="text-decoration-none">
46
+ <div class="card card-custom p-4 h-100 border-start border-4 border-danger">
47
+ <div class="d-flex align-items-center mb-3">
48
+ <div class="bg-danger bg-opacity-10 p-3 rounded-3 text-danger me-3">
49
+ <i class="fas fa-file-pdf fa-2x"></i>
50
+ </div>
51
+ <h5 class="mb-0 fw-bold text-dark">Smart PDF</h5>
52
+ </div>
53
+ <p class="text-muted small mb-0">Tóm tắt & trích xuất dữ liệu từ PDF bằng Gemini AI.</p>
54
+ </div>
55
+ </a>
56
+ </div>
57
+
58
+ <!-- Image Studio -->
59
+ <div class="col-md-4">
60
+ <a href="/image-studio" class="text-decoration-none">
61
+ <div class="card card-custom p-4 h-100 border-start border-4 border-success">
62
+ <div class="d-flex align-items-center mb-3">
63
+ <div class="bg-success bg-opacity-10 p-3 rounded-3 text-success me-3">
64
+ <i class="fas fa-image fa-2x"></i>
65
+ </div>
66
+ <h5 class="mb-0 fw-bold text-dark">Image Studio</h5>
67
+ </div>
68
+ <p class="text-muted small mb-0">Xóa nền ảnh tự động & nâng cấp độ phân giải bằng AI.</p>
69
+ </div>
70
+ </a>
71
+ </div>
72
+
73
+ </div>
74
+ {% endblock %}
templates/layout.html ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{% block title %}Pum's Toolkit{% endblock %}</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap" rel="stylesheet">
10
+ <link rel="stylesheet" href="{{ url_for('static', filename='theme.css') }}">
11
+ {% block styles %}{% endblock %}
12
+ </head>
13
+ <body>
14
+
15
+ <div class="sidebar">
16
+ <div class="brand">
17
+ <img src="{{ url_for('static', filename='logo.png') }}" alt="Pum's Toolkit Logo">
18
+ <span class="brand-name">PUM'S TOOLKIT</span>
19
+ </div>
20
+ <nav class="nav flex-column">
21
+ <a class="nav-link {% if request.path == '/' %}active{% endif %}" href="/">
22
+ <i class="fas fa-th-large"></i> Dashboard
23
+ </a>
24
+ <a class="nav-link nav-link-collapsible {% if request.path.startswith('/translator') %}active{% endif %}"
25
+ href="/translator/text"
26
+ data-bs-toggle="collapse"
27
+ data-bs-target="#translator-sub"
28
+ aria-expanded="{{ 'true' if request.path.startswith('/translator') else 'false' }}"
29
+ onclick="return handleNavCollapse(event, this)">
30
+ <span><i class="fas fa-language"></i> AI Translator</span>
31
+ <i class="fas fa-chevron-down nav-chevron {% if not request.path.startswith('/translator') %}collapsed{% endif %}"></i>
32
+ </a>
33
+ <div class="collapse nav-sub {% if request.path.startswith('/translator') %}show{% endif %}" id="translator-sub">
34
+ <a class="nav-link nav-link-sub {% if request.path == '/translator/text' %}active{% endif %}" href="/translator/text">
35
+ <i class="fas fa-font"></i> Văn bản
36
+ </a>
37
+ <a class="nav-link nav-link-sub {% if request.path == '/translator/file' %}active{% endif %}" href="/translator/file">
38
+ <i class="fas fa-file-alt"></i> Tài liệu
39
+ </a>
40
+ <a class="nav-link nav-link-sub {% if request.path == '/translator/image' %}active{% endif %}" href="/translator/image">
41
+ <i class="fas fa-image"></i> Dịch ảnh
42
+ </a>
43
+ </div>
44
+ <a class="nav-link nav-link-collapsible {% if request.path.startswith('/image-studio') %}active{% endif %}"
45
+ href="/image-studio/remove-bg"
46
+ data-bs-toggle="collapse"
47
+ data-bs-target="#image-sub"
48
+ aria-expanded="{{ 'true' if request.path.startswith('/image-studio') else 'false' }}"
49
+ onclick="return handleNavCollapse(event, this)">
50
+ <span><i class="fas fa-image"></i> Image Studio</span>
51
+ <i class="fas fa-chevron-down nav-chevron {% if not request.path.startswith('/image-studio') %}collapsed{% endif %}"></i>
52
+ </a>
53
+ <div class="collapse nav-sub {% if request.path.startswith('/image-studio') %}show{% endif %}" id="image-sub">
54
+ <a class="nav-link nav-link-sub {% if request.path == '/image-studio/remove-bg' %}active{% endif %}" href="/image-studio/remove-bg">
55
+ <i class="fas fa-cut"></i> Xóa nền
56
+ </a>
57
+ <a class="nav-link nav-link-sub {% if request.path == '/image-studio/upscale' %}active{% endif %}" href="/image-studio/upscale">
58
+ <i class="fas fa-expand-arrows-alt"></i> Nâng cấp ảnh
59
+ </a>
60
+ </div>
61
+ <a class="nav-link nav-link-collapsible {% if request.path.startswith('/tts') %}active{% endif %}"
62
+ href="/tts/vieneu"
63
+ data-bs-toggle="collapse"
64
+ data-bs-target="#tts-sub"
65
+ aria-expanded="{{ 'true' if request.path.startswith('/tts') else 'false' }}"
66
+ onclick="return handleNavCollapse(event, this)">
67
+ <span><i class="fas fa-microphone-alt"></i> Text to Speech</span>
68
+ <i class="fas fa-chevron-down nav-chevron {% if not request.path.startswith('/tts') %}collapsed{% endif %}"></i>
69
+ </a>
70
+ <div class="collapse nav-sub {% if request.path.startswith('/tts') %}show{% endif %}" id="tts-sub">
71
+ <a class="nav-link nav-link-sub {% if request.path == '/tts/vieneu' %}active{% endif %}" href="/tts/vieneu">
72
+ <i class="fas fa-dragon"></i> VieNeu-TTS
73
+ </a>
74
+ <a class="nav-link nav-link-sub {% if request.path == '/tts/gtts' %}active{% endif %}" href="/tts/gtts">
75
+ <i class="fab fa-google"></i> Google TTS
76
+ </a>
77
+ <a class="nav-link nav-link-sub {% if request.path == '/tts/edge' %}active{% endif %}" href="/tts/edge">
78
+ <i class="fab fa-microsoft"></i> Edge TTS
79
+ </a>
80
+ </div>
81
+ <a class="nav-link nav-link-collapsible {% if request.path.startswith('/pdf') %}active{% endif %}"
82
+ href="/pdf/summarize"
83
+ data-bs-toggle="collapse"
84
+ data-bs-target="#pdf-sub"
85
+ aria-expanded="{{ 'true' if request.path.startswith('/pdf') else 'false' }}"
86
+ onclick="return handleNavCollapse(event, this)">
87
+ <span><i class="fas fa-file-pdf"></i> Smart PDF</span>
88
+ <i class="fas fa-chevron-down nav-chevron {% if not request.path.startswith('/pdf') %}collapsed{% endif %}"></i>
89
+ </a>
90
+ <div class="collapse nav-sub {% if request.path.startswith('/pdf') %}show{% endif %}" id="pdf-sub">
91
+ <a class="nav-link nav-link-sub {% if request.path == '/pdf/summarize' %}active{% endif %}" href="/pdf/summarize">
92
+ <i class="fas fa-align-left"></i> Tóm tắt
93
+ </a>
94
+ <a class="nav-link nav-link-sub {% if request.path == '/pdf/extract' %}active{% endif %}" href="/pdf/extract">
95
+ <i class="fas fa-table"></i> Trích xuất dữ liệu
96
+ </a>
97
+ </div>
98
+ </nav>
99
+ </div>
100
+
101
+ <div class="main-content w-100">
102
+ {% block content %}{% endblock %}
103
+ </div>
104
+
105
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
106
+ <script>
107
+ // Nav collapse: click khi đã mở → chỉ toggle, không navigate
108
+ // Click khi đang đóng → navigate đến link VÀ mở collapse
109
+ function handleNavCollapse(e, el) {
110
+ const target = document.querySelector(el.getAttribute('data-bs-target'));
111
+ if (target && target.classList.contains('show')) {
112
+ // Đang mở → chỉ toggle đóng, không navigate
113
+ e.preventDefault();
114
+ }
115
+ // Nếu đang đóng → để link navigate bình thường (server sẽ render với show)
116
+ return true;
117
+ }
118
+
119
+ // ── Paste ảnh (Ctrl+V) cho tất cả drop zone ──────────────
120
+ document.addEventListener('paste', (e) => {
121
+ const items = e.clipboardData?.items;
122
+ if (!items) return;
123
+
124
+ for (const item of items) {
125
+ if (!item.type.startsWith('image/')) continue;
126
+
127
+ const file = item.getAsFile();
128
+ if (!file) continue;
129
+
130
+ // Tìm drop zone đầu tiên trên trang (hoặc drop zone đang được hover)
131
+ const dropZone = document.querySelector('.drop-zone, .file-upload-wrapper');
132
+ if (!dropZone) return;
133
+
134
+ // Tìm input[type=file] trong drop zone
135
+ const fileInput = dropZone.querySelector('input[type="file"]');
136
+ if (!fileInput) return;
137
+
138
+ // Inject file vào input
139
+ const dt = new DataTransfer();
140
+ dt.items.add(file);
141
+ fileInput.files = dt.files;
142
+
143
+ // Trigger change event để các handler tự xử lý
144
+ fileInput.dispatchEvent(new Event('change', { bubbles: true }));
145
+
146
+ // Hiệu ứng flash để user biết đã paste
147
+ dropZone.style.transition = 'background 0.15s';
148
+ dropZone.style.background = 'var(--color-primary-hover)';
149
+ dropZone.style.borderColor = 'var(--color-primary)';
150
+ setTimeout(() => {
151
+ dropZone.style.background = '';
152
+ dropZone.style.borderColor = '';
153
+ }, 600);
154
+
155
+ e.preventDefault();
156
+ break;
157
+ }
158
+ });
159
+
160
+ document.querySelectorAll('[data-bs-toggle="collapse"]').forEach(el => {
161
+ const targetId = el.getAttribute('data-bs-target');
162
+ const target = document.querySelector(targetId);
163
+ if (!target) return;
164
+ target.addEventListener('show.bs.collapse', () => {
165
+ el.querySelector('.nav-chevron')?.classList.remove('collapsed');
166
+ });
167
+ target.addEventListener('hide.bs.collapse', () => {
168
+ el.querySelector('.nav-chevron')?.classList.add('collapsed');
169
+ });
170
+ });
171
+ </script>
172
+ {% block scripts %}{% endblock %}
173
+ </body>
174
+ </html>
templates/tools/image_studio/index.html ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Image Studio - Pum's Toolkit{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .drop-zone {
8
+ border: 2px dashed var(--color-border-input);
9
+ border-radius: var(--radius-lg);
10
+ padding: 2.5rem;
11
+ text-align: center;
12
+ cursor: pointer;
13
+ transition: border-color 0.2s, background 0.2s;
14
+ background: var(--color-bg-input);
15
+ position: relative;
16
+ }
17
+ .drop-zone:hover, .drop-zone.drag-over {
18
+ border-color: var(--color-primary);
19
+ background: var(--color-primary-hover);
20
+ }
21
+ .drop-zone input[type="file"] {
22
+ position: absolute; inset: 0; opacity: 0; cursor: pointer;
23
+ }
24
+ .preview-box {
25
+ border-radius: var(--radius-lg);
26
+ overflow: hidden;
27
+ background: repeating-conic-gradient(#e0e0e0 0% 25%, #fff 0% 50%) 0 0 / 20px 20px;
28
+ display: flex; align-items: center; justify-content: center;
29
+ min-height: 220px;
30
+ position: relative;
31
+ }
32
+ .preview-box img {
33
+ max-width: 100%; max-height: 320px;
34
+ object-fit: contain; display: block;
35
+ }
36
+ .preview-box .placeholder {
37
+ color: #adb5bd; font-size: 0.85rem; padding: 2rem;
38
+ text-align: center;
39
+ }
40
+ .tool-tab-btn {
41
+ border: 1.5px solid var(--color-border);
42
+ border-radius: var(--radius-md);
43
+ padding: 0.8rem 1.5rem;
44
+ font-weight: var(--font-weight-semi);
45
+ font-size: 0.9rem;
46
+ color: var(--color-text-muted);
47
+ background: #fff;
48
+ cursor: pointer;
49
+ transition: all 0.15s;
50
+ display: flex; align-items: center; gap: 8px;
51
+ text-decoration: none;
52
+ }
53
+ .tool-tab-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
54
+ .tool-tab-btn.active {
55
+ background: var(--color-primary); color: #fff; border-color: var(--color-primary);
56
+ box-shadow: 0 2px 8px rgba(67,97,238,0.25);
57
+ }
58
+ .result-badge {
59
+ position: absolute; top: 10px; right: 10px;
60
+ background: rgba(67,97,238,0.85); color: #fff;
61
+ border-radius: 6px; font-size: 0.72rem; padding: 3px 8px;
62
+ font-weight: 600;
63
+ }
64
+ </style>
65
+ {% endblock %}
66
+
67
+ {% block content %}
68
+ <div id="loaderOverlay" class="loader-overlay">
69
+ <div class="spinner-pro"></div>
70
+ <p class="mt-3 fw-bold" style="color: var(--color-primary)" id="loaderMsg">Đang xử lý ảnh...</p>
71
+ </div>
72
+
73
+ <div class="card card-custom">
74
+ <div class="card-header bg-white border-0 pt-4 text-center">
75
+ <h3 class="fw-bold" style="color: var(--color-primary)">IMAGE STUDIO</h3>
76
+ <p class="text-muted mb-0">Xóa nền & Nâng cấp ảnh bằng AI</p>
77
+ </div>
78
+
79
+ <div class="card-body p-4">
80
+
81
+ <!-- Tool Selector -->
82
+ <div class="d-flex gap-3 mb-4 justify-content-center flex-wrap">
83
+ <a class="tool-tab-btn {% if active_tool == 'remove-bg' %}active{% endif %}" href="/image-studio/remove-bg">
84
+ <i class="fas fa-cut"></i> Xóa nền
85
+ </a>
86
+ <a class="tool-tab-btn {% if active_tool == 'upscale' %}active{% endif %}" href="/image-studio/upscale">
87
+ <i class="fas fa-expand-arrows-alt"></i> Nâng cấp ảnh
88
+ </a>
89
+ </div>
90
+
91
+ <!-- Upload Zone -->
92
+ <div class="drop-zone mb-4" id="dropZone" onclick="document.getElementById('imageInput').click()">
93
+ <input type="file" id="imageInput" accept="image/jpeg,image/png,image/webp">
94
+ <i class="fas fa-cloud-upload-alt fa-2x text-muted mb-2 d-block"></i>
95
+ <div class="fw-semibold text-muted">Kéo thả ảnh vào đây hoặc click để chọn</div>
96
+ <div class="small text-muted mt-1">Hỗ trợ JPG, PNG, WEBP — tối đa 16MB</div>
97
+ <div class="small text-muted mt-1" style="opacity:0.6"><i class="fas fa-paste me-1"></i>Hoặc nhấn <kbd>Ctrl+V</kbd> để dán ảnh từ clipboard</div>
98
+ <div id="uploadedFileName" class="mt-2 fw-bold d-none" style="color: var(--color-primary)"></div>
99
+ </div>
100
+
101
+ <!-- Upscale options (chỉ hiện khi chọn upscale) -->
102
+ <div id="upscaleOptions" class="mb-4 d-none">
103
+ <label class="form-label">Hệ số phóng to</label>
104
+ <div class="d-flex gap-2">
105
+ <button class="btn btn-outline-primary scale-btn active" data-scale="2">2x</button>
106
+ <button class="btn btn-outline-primary scale-btn" data-scale="3">3x</button>
107
+ <button class="btn btn-outline-primary scale-btn" data-scale="4">4x</button>
108
+ </div>
109
+ </div>
110
+
111
+ <!-- Action Button -->
112
+ <button class="btn-primary-custom mb-4" id="processBtn" disabled>
113
+ <i class="fas fa-magic me-2"></i> <span id="processBtnText">Xử lý ảnh</span>
114
+ </button>
115
+
116
+ <!-- Error -->
117
+ <div id="errorArea" class="mb-3 d-none">
118
+ <div class="alert alert-danger d-flex align-items-center gap-2 mb-0">
119
+ <i class="fas fa-exclamation-circle"></i>
120
+ <span id="errorMsg"></span>
121
+ </div>
122
+ </div>
123
+
124
+ <!-- Preview: Before / After -->
125
+ <div id="previewSection" class="d-none">
126
+ <hr class="my-3">
127
+ <div class="row g-3 align-items-start">
128
+ <div class="col-md-6">
129
+ <div class="form-label fw-semibold mb-2"><i class="fas fa-image me-1"></i> Ảnh gốc</div>
130
+ <div class="preview-box">
131
+ <img id="imgOriginal" src="" alt="Ảnh gốc">
132
+ </div>
133
+ <div class="small text-muted mt-1" id="originalInfo"></div>
134
+ </div>
135
+ <div class="col-md-6">
136
+ <div class="form-label fw-semibold mb-2"><i class="fas fa-star me-1 text-warning"></i> Kết quả</div>
137
+ <div class="preview-box">
138
+ <div class="placeholder" id="resultPlaceholder">
139
+ <i class="fas fa-clock fa-2x mb-2 d-block"></i>
140
+ Chưa xử lý
141
+ </div>
142
+ <img id="imgResult" src="" alt="Kết quả" style="display:none">
143
+ <span class="result-badge d-none" id="resultBadge"></span>
144
+ </div>
145
+ <div class="small text-muted mt-1" id="resultInfo"></div>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- Download -->
150
+ <div id="downloadArea" class="mt-4 text-center d-none">
151
+ <div class="alert alert-success d-flex align-items-center justify-content-center gap-3 mb-0">
152
+ <i class="fas fa-check-circle fa-lg"></i>
153
+ <span>Xử lý xong!</span>
154
+ <a id="downloadLink" href="#" class="btn btn-success btn-sm px-3">
155
+ <i class="fas fa-download me-1"></i> Tải về
156
+ </a>
157
+ </div>
158
+ </div>
159
+ </div>
160
+
161
+ </div>
162
+ </div>
163
+ {% endblock %}
164
+
165
+ {% block scripts %}
166
+ <script>
167
+ let currentTool = '{{ active_tool }}';
168
+ let selectedFile = null;
169
+ let selectedScale = 2;
170
+
171
+ // Hiện upscale options nếu đang ở trang upscale
172
+ if (currentTool === 'upscale') {
173
+ document.getElementById('upscaleOptions').classList.remove('d-none');
174
+ document.getElementById('processBtnText').textContent = 'Nâng cấp ảnh';
175
+ } else {
176
+ document.getElementById('processBtnText').textContent = 'Xóa nền';
177
+ }
178
+
179
+ // Scale buttons
180
+ document.querySelectorAll('.scale-btn').forEach(btn => {
181
+ btn.addEventListener('click', () => {
182
+ document.querySelectorAll('.scale-btn').forEach(b => b.classList.remove('active'));
183
+ btn.classList.add('active');
184
+ selectedScale = parseInt(btn.dataset.scale);
185
+ });
186
+ });
187
+
188
+ // Drag & drop
189
+ const dropZone = document.getElementById('dropZone');
190
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
191
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
192
+ dropZone.addEventListener('drop', e => {
193
+ e.preventDefault();
194
+ dropZone.classList.remove('drag-over');
195
+ const f = e.dataTransfer.files[0];
196
+ if (f) handleFileSelected(f);
197
+ });
198
+
199
+ // File input
200
+ document.getElementById('imageInput').addEventListener('change', e => {
201
+ if (e.target.files[0]) handleFileSelected(e.target.files[0]);
202
+ });
203
+
204
+ function handleFileSelected(file) {
205
+ selectedFile = file;
206
+ document.getElementById('uploadedFileName').textContent = `✓ ${file.name}`;
207
+ document.getElementById('uploadedFileName').classList.remove('d-none');
208
+ document.getElementById('processBtn').disabled = false;
209
+
210
+ // Show preview original
211
+ const reader = new FileReader();
212
+ reader.onload = e => {
213
+ document.getElementById('imgOriginal').src = e.target.result;
214
+ document.getElementById('originalInfo').textContent =
215
+ `${file.name} — ${(file.size / 1024).toFixed(0)} KB`;
216
+ document.getElementById('previewSection').classList.remove('d-none');
217
+ };
218
+ reader.readAsDataURL(file);
219
+ resetResult();
220
+ }
221
+
222
+ function resetResult() {
223
+ document.getElementById('imgResult').src = '';
224
+ document.getElementById('imgResult').style.display = 'none';
225
+ document.getElementById('resultPlaceholder').style.display = 'block';
226
+ document.getElementById('resultBadge').classList.add('d-none');
227
+ document.getElementById('downloadArea').classList.add('d-none');
228
+ document.getElementById('resultInfo').textContent = '';
229
+ document.getElementById('errorArea').classList.add('d-none');
230
+ }
231
+
232
+ function showError(msg) {
233
+ document.getElementById('errorMsg').textContent = msg;
234
+ document.getElementById('errorArea').classList.remove('d-none');
235
+ }
236
+
237
+ // Process
238
+ document.getElementById('processBtn').addEventListener('click', async () => {
239
+ if (!selectedFile) return;
240
+
241
+ const loader = document.getElementById('loaderOverlay');
242
+ const loaderMsg = document.getElementById('loaderMsg');
243
+ loaderMsg.textContent = currentTool === 'rembg' ? 'Đang xóa nền...' : 'Đang nâng cấp ảnh...';
244
+ loader.style.display = 'flex';
245
+ document.getElementById('errorArea').classList.add('d-none');
246
+
247
+ const formData = new FormData();
248
+ formData.append('image', selectedFile);
249
+
250
+ let url = '/image/remove-bg';
251
+ if (currentTool === 'upscale') {
252
+ url = '/image/upscale';
253
+ formData.append('scale', selectedScale);
254
+ }
255
+
256
+ try {
257
+ const res = await fetch(url, { method: 'POST', body: formData });
258
+ const data = await res.json();
259
+
260
+ if (data.error) {
261
+ showError(data.error);
262
+ return;
263
+ }
264
+
265
+ // Show result
266
+ document.getElementById('imgResult').src = data.result_url + '?t=' + Date.now();
267
+ document.getElementById('imgResult').style.display = 'block';
268
+ document.getElementById('resultPlaceholder').style.display = 'none';
269
+
270
+ if (currentTool === 'rembg') {
271
+ document.getElementById('resultBadge').textContent = 'Đã xóa nền';
272
+ document.getElementById('resultBadge').classList.remove('d-none');
273
+ document.getElementById('resultInfo').textContent = 'PNG trong suốt, sẵn sàng tải về';
274
+ } else {
275
+ document.getElementById('resultBadge').textContent = `${selectedScale}x`;
276
+ document.getElementById('resultBadge').classList.remove('d-none');
277
+ document.getElementById('resultInfo').textContent =
278
+ `${data.original_size} → ${data.new_size}`;
279
+ }
280
+
281
+ document.getElementById('downloadLink').href = data.result_url;
282
+ document.getElementById('downloadLink').download = data.filename;
283
+ document.getElementById('downloadArea').classList.remove('d-none');
284
+
285
+ } catch(e) {
286
+ showError('Lỗi kết nối. Vui lòng thử lại.');
287
+ } finally {
288
+ loader.style.display = 'none';
289
+ }
290
+ });
291
+ </script>
292
+ {% endblock %}
templates/tools/smart_pdf/index.html ADDED
@@ -0,0 +1,351 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Smart PDF - Pum's Toolkit{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .tool-tab-btn {
8
+ border: 1.5px solid var(--color-border);
9
+ border-radius: var(--radius-md);
10
+ padding: 0.8rem 1.5rem;
11
+ font-weight: var(--font-weight-semi);
12
+ font-size: 0.9rem;
13
+ color: var(--color-text-muted);
14
+ background: #fff;
15
+ cursor: pointer;
16
+ transition: all 0.15s;
17
+ display: flex; align-items: center; gap: 8px;
18
+ text-decoration: none;
19
+ }
20
+ .tool-tab-btn:hover { border-color: var(--color-primary); color: var(--color-primary); }
21
+ .tool-tab-btn.active {
22
+ background: var(--color-primary); color: #fff;
23
+ border-color: var(--color-primary);
24
+ box-shadow: 0 2px 8px rgba(67,97,238,0.25);
25
+ }
26
+ .drop-zone {
27
+ border: 2px dashed var(--color-border-input);
28
+ border-radius: var(--radius-lg);
29
+ padding: 2.5rem;
30
+ text-align: center;
31
+ cursor: pointer;
32
+ transition: border-color 0.2s, background 0.2s;
33
+ background: var(--color-bg-input);
34
+ position: relative;
35
+ }
36
+ .drop-zone:hover, .drop-zone.drag-over {
37
+ border-color: var(--color-primary);
38
+ background: var(--color-primary-hover);
39
+ }
40
+ .drop-zone input[type="file"] {
41
+ position: absolute; inset: 0; opacity: 0; cursor: pointer;
42
+ }
43
+ .result-box {
44
+ background: var(--color-bg-input);
45
+ border: 1px solid var(--color-border);
46
+ border-radius: var(--radius-lg);
47
+ padding: 1.5rem;
48
+ min-height: 200px;
49
+ font-size: 0.875rem;
50
+ line-height: 1.7;
51
+ white-space: pre-wrap;
52
+ word-break: break-word;
53
+ overflow-y: auto;
54
+ max-height: 500px;
55
+ }
56
+ .result-box table {
57
+ border-collapse: collapse;
58
+ width: 100%;
59
+ font-size: 0.82rem;
60
+ }
61
+ .result-box th, .result-box td {
62
+ border: 1px solid var(--color-border);
63
+ padding: 6px 10px;
64
+ text-align: left;
65
+ }
66
+ .result-box th { background: var(--color-primary-light); font-weight: 600; }
67
+ .extract-type-btn {
68
+ border: 1.5px solid var(--color-border);
69
+ border-radius: var(--radius-sm);
70
+ padding: 0.4rem 0.9rem;
71
+ font-size: 0.82rem;
72
+ font-weight: 600;
73
+ color: var(--color-text-muted);
74
+ background: #fff;
75
+ cursor: pointer;
76
+ transition: all 0.15s;
77
+ }
78
+ .extract-type-btn.active {
79
+ background: var(--color-primary-light);
80
+ color: var(--color-primary);
81
+ border-color: var(--color-primary);
82
+ }
83
+ </style>
84
+ {% endblock %}
85
+
86
+ {% block content %}
87
+ <div id="loaderOverlay" class="loader-overlay">
88
+ <div class="spinner-pro"></div>
89
+ <p class="mt-3 fw-bold" style="color: var(--color-primary)" id="loaderMsg">Đang xử lý PDF...</p>
90
+ </div>
91
+
92
+ <div class="card card-custom">
93
+ <div class="card-header bg-white border-0 pt-4 text-center">
94
+ <h3 class="fw-bold" style="color: var(--color-primary)">SMART PDF</h3>
95
+ <p class="text-muted mb-0">Tóm tắt & Trích xuất dữ liệu từ PDF bằng Gemini AI</p>
96
+ </div>
97
+
98
+ <div class="card-body p-4">
99
+
100
+ <!-- Tool tabs -->
101
+ <div class="d-flex gap-3 mb-4 justify-content-center flex-wrap">
102
+ <a class="tool-tab-btn {% if active_tool == 'summarize' %}active{% endif %}" href="/pdf/summarize">
103
+ <i class="fas fa-align-left"></i> Tóm tắt
104
+ </a>
105
+ <a class="tool-tab-btn {% if active_tool == 'extract' %}active{% endif %}" href="/pdf/extract">
106
+ <i class="fas fa-table"></i> Trích xuất dữ liệu
107
+ </a>
108
+ </div>
109
+
110
+ <!-- Upload PDF -->
111
+ <div class="drop-zone mb-4" id="dropZone" onclick="document.getElementById('pdfInput').click()">
112
+ <input type="file" id="pdfInput" accept=".pdf">
113
+ <i class="fas fa-file-pdf fa-2x text-danger mb-2 d-block"></i>
114
+ <div class="fw-semibold text-muted">Kéo thả file PDF vào đây hoặc click để chọn</div>
115
+ <div class="small text-muted mt-1">Chỉ hỗ trợ PDF — tối đa 16MB</div>
116
+ <div class="small text-muted mt-1" style="opacity:0.6"><i class="fas fa-paste me-1"></i>Hoặc nhấn <kbd>Ctrl+V</kbd> nếu copy file PDF</div>
117
+ <div id="uploadedFileName" class="mt-2 fw-bold d-none" style="color: var(--color-primary)"></div>
118
+ </div>
119
+
120
+ <!-- Gemini model -->
121
+ <div class="row g-3 mb-4">
122
+ <div class="col-md-6">
123
+ <label class="form-label">Mô hình Gemini</label>
124
+ <select id="geminiModel" class="form-select">
125
+ <option value="">Đang tải...</option>
126
+ </select>
127
+ <div id="modelQuota" class="mt-1 small text-muted"></div>
128
+ </div>
129
+ <div class="col-md-6">
130
+ <label class="form-label">Ngôn ngữ kết quả</label>
131
+ <select id="outputLang" class="form-select">
132
+ <option value="vi" selected>Tiếng Việt</option>
133
+ <option value="en">English</option>
134
+ <option value="ja">日本語</option>
135
+ <option value="zh-CN">中文</option>
136
+ <option value="ko">한국어</option>
137
+ <option value="fr">Français</option>
138
+ <option value="de">Deutsch</option>
139
+ </select>
140
+ </div>
141
+ </div>
142
+
143
+ <!-- Extract options (chỉ hiện khi chọn extract) -->
144
+ <div id="extractOptions" class="mb-4 {% if active_tool != 'extract' %}d-none{% endif %}">
145
+ <label class="form-label">Loại dữ liệu cần trích xuất</label>
146
+ <div class="d-flex gap-2 flex-wrap">
147
+ <button class="extract-type-btn active" data-type="table">
148
+ <i class="fas fa-table me-1"></i> Bảng dữ liệu
149
+ </button>
150
+ <button class="extract-type-btn" data-type="key_value">
151
+ <i class="fas fa-tag me-1"></i> Thông tin chính
152
+ </button>
153
+ <button class="extract-type-btn" data-type="entity">
154
+ <i class="fas fa-user-tag me-1"></i> Thực thể (Tên, Ngày, Số liệu)
155
+ </button>
156
+ <button class="extract-type-btn" data-type="custom">
157
+ <i class="fas fa-pen me-1"></i> Tùy chỉnh
158
+ </button>
159
+ </div>
160
+ <div id="customPromptBox" class="mt-3 d-none">
161
+ <textarea id="customPrompt" class="form-control" rows="2"
162
+ placeholder="Nhập yêu cầu trích xuất, ví dụ: tất cả các số hợp đồng và giá trị..."></textarea>
163
+ </div>
164
+ </div>
165
+
166
+ <!-- Action button -->
167
+ <button class="btn-primary-custom mb-4" id="processBtn" disabled>
168
+ <i class="fas fa-magic me-2"></i>
169
+ <span id="processBtnText">{% if active_tool == 'summarize' %}Tóm tắt PDF{% else %}Trích xuất dữ liệu{% endif %}</span>
170
+ </button>
171
+
172
+ <!-- Error -->
173
+ <div id="errorArea" class="mb-3 d-none">
174
+ <div class="alert alert-danger d-flex align-items-center gap-2 mb-0">
175
+ <i class="fas fa-exclamation-circle"></i>
176
+ <span id="errorMsg"></span>
177
+ </div>
178
+ </div>
179
+
180
+ <!-- Result -->
181
+ <div id="resultSection" class="d-none">
182
+ <hr class="my-3">
183
+ <div class="d-flex justify-content-between align-items-center mb-2">
184
+ <div class="fw-semibold">
185
+ <i class="fas fa-check-circle text-success me-1"></i> Kết quả
186
+ <span id="truncatedBadge" class="badge bg-warning text-dark ms-2 d-none">
187
+ Chỉ xử lý 15.000 ký tự đầu
188
+ </span>
189
+ </div>
190
+ <div class="d-flex gap-2">
191
+ <button class="btn btn-sm btn-outline-secondary" onclick="copyResult()">
192
+ <i class="fas fa-copy me-1"></i> Sao chép
193
+ </button>
194
+ <button class="btn btn-sm btn-outline-primary" onclick="downloadResult()">
195
+ <i class="fas fa-download me-1"></i> Tải .txt
196
+ </button>
197
+ </div>
198
+ </div>
199
+ <div class="result-box" id="resultBox"></div>
200
+ </div>
201
+
202
+ </div>
203
+ </div>
204
+ {% endblock %}
205
+
206
+ {% block scripts %}
207
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
208
+ <script>
209
+ const activeTool = '{{ active_tool }}';
210
+ let selectedFile = null;
211
+ let selectedExtractType = 'table';
212
+ let geminiModelList = [];
213
+
214
+ // Load Gemini models
215
+ async function loadGeminiModels() {
216
+ try {
217
+ const res = await fetch('/api/gemini-models');
218
+ geminiModelList = await res.json();
219
+ const sel = document.getElementById('geminiModel');
220
+ sel.innerHTML = '';
221
+ geminiModelList.forEach(m => {
222
+ const opt = document.createElement('option');
223
+ opt.value = m.id;
224
+ const quota = m.remaining > 0
225
+ ? `✓ Còn ${m.remaining.toLocaleString()}/${m.rpd}`
226
+ : `✗ Hết`;
227
+ opt.textContent = `${m.name} — ${quota}`;
228
+ if (m.remaining === 0) opt.disabled = true;
229
+ sel.appendChild(opt);
230
+ });
231
+ updateModelQuota();
232
+ } catch(e) { console.error(e); }
233
+ }
234
+
235
+ function updateModelQuota() {
236
+ const sel = document.getElementById('geminiModel');
237
+ const chosen = geminiModelList.find(m => m.id === sel.value);
238
+ const el = document.getElementById('modelQuota');
239
+ if (chosen) {
240
+ el.innerHTML = chosen.remaining > 0
241
+ ? `<span style="color:var(--color-primary)"><i class="fas fa-check-circle"></i> Còn <strong>${chosen.remaining.toLocaleString()}</strong> request hôm nay</span>`
242
+ : `<span style="color:#dc3545"><i class="fas fa-exclamation-triangle"></i> Đã hết quota</span>`;
243
+ }
244
+ }
245
+
246
+ document.getElementById('geminiModel').addEventListener('change', updateModelQuota);
247
+ loadGeminiModels();
248
+
249
+ // Drag & drop
250
+ const dropZone = document.getElementById('dropZone');
251
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
252
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
253
+ dropZone.addEventListener('drop', e => {
254
+ e.preventDefault();
255
+ dropZone.classList.remove('drag-over');
256
+ if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
257
+ });
258
+ document.getElementById('pdfInput').addEventListener('change', e => {
259
+ if (e.target.files[0]) handleFile(e.target.files[0]);
260
+ });
261
+
262
+ function handleFile(file) {
263
+ selectedFile = file;
264
+ document.getElementById('uploadedFileName').textContent = `✓ ${file.name} (${(file.size/1024).toFixed(0)} KB)`;
265
+ document.getElementById('uploadedFileName').classList.remove('d-none');
266
+ document.getElementById('processBtn').disabled = false;
267
+ document.getElementById('resultSection').classList.add('d-none');
268
+ document.getElementById('errorArea').classList.add('d-none');
269
+ }
270
+
271
+ // Extract type buttons
272
+ document.querySelectorAll('.extract-type-btn').forEach(btn => {
273
+ btn.addEventListener('click', () => {
274
+ document.querySelectorAll('.extract-type-btn').forEach(b => b.classList.remove('active'));
275
+ btn.classList.add('active');
276
+ selectedExtractType = btn.dataset.type;
277
+ document.getElementById('customPromptBox').classList.toggle('d-none', selectedExtractType !== 'custom');
278
+ });
279
+ });
280
+
281
+ // Process
282
+ document.getElementById('processBtn').addEventListener('click', async () => {
283
+ if (!selectedFile) return;
284
+
285
+ document.getElementById('loaderMsg').textContent =
286
+ activeTool === 'summarize' ? 'Đang tóm tắt PDF...' : 'Đang trích xuất dữ liệu...';
287
+ document.getElementById('loaderOverlay').style.display = 'flex';
288
+ document.getElementById('errorArea').classList.add('d-none');
289
+ document.getElementById('resultSection').classList.add('d-none');
290
+
291
+ const formData = new FormData();
292
+ formData.append('file', selectedFile);
293
+ formData.append('tool', activeTool);
294
+ formData.append('gemini_model', document.getElementById('geminiModel').value);
295
+ formData.append('lang', document.getElementById('outputLang').value);
296
+ if (activeTool === 'extract') {
297
+ formData.append('extract_type', selectedExtractType);
298
+ if (selectedExtractType === 'custom') {
299
+ formData.append('custom_prompt', document.getElementById('customPrompt').value);
300
+ }
301
+ }
302
+
303
+ try {
304
+ const res = await fetch('/pdf/process', { method: 'POST', body: formData });
305
+ const data = await res.json();
306
+
307
+ if (data.error) {
308
+ document.getElementById('errorMsg').textContent = data.error;
309
+ document.getElementById('errorArea').classList.remove('d-none');
310
+ return;
311
+ }
312
+
313
+ // Render markdown trong result box
314
+ document.getElementById('resultBox').innerHTML = marked.parse(data.result);
315
+ document.getElementById('resultSection').classList.remove('d-none');
316
+
317
+ if (data.truncated) {
318
+ document.getElementById('truncatedBadge').classList.remove('d-none');
319
+ } else {
320
+ document.getElementById('truncatedBadge').classList.add('d-none');
321
+ }
322
+
323
+ await loadGeminiModels(); // Cập nhật quota
324
+
325
+ } catch(e) {
326
+ document.getElementById('errorMsg').textContent = 'Lỗi kết nối. Vui lòng thử lại.';
327
+ document.getElementById('errorArea').classList.remove('d-none');
328
+ } finally {
329
+ document.getElementById('loaderOverlay').style.display = 'none';
330
+ }
331
+ });
332
+
333
+ function copyResult() {
334
+ const text = document.getElementById('resultBox').innerText;
335
+ navigator.clipboard.writeText(text).then(() => {
336
+ const btn = event.target.closest('button');
337
+ btn.innerHTML = '<i class="fas fa-check me-1"></i> Đã sao chép';
338
+ setTimeout(() => btn.innerHTML = '<i class="fas fa-copy me-1"></i> Sao chép', 2000);
339
+ });
340
+ }
341
+
342
+ function downloadResult() {
343
+ const text = document.getElementById('resultBox').innerText;
344
+ const blob = new Blob([text], { type: 'text/plain;charset=utf-8' });
345
+ const a = document.createElement('a');
346
+ a.href = URL.createObjectURL(blob);
347
+ a.download = `pdf_result_${Date.now()}.txt`;
348
+ a.click();
349
+ }
350
+ </script>
351
+ {% endblock %}
templates/tools/translator/image.html ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}AI Translator Pro - Pum's Toolkit{% endblock %}
4
+
5
+ {% block styles %}
6
+ <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
7
+ <style>
8
+ .select2-container .select2-selection--single {
9
+ height: calc(1.5em + 0.75rem + 2px);
10
+ border-color: var(--color-border-input);
11
+ border-radius: var(--radius-md);
12
+ display: flex; align-items: center;
13
+ }
14
+ .select2-container--default .select2-selection--single .select2-selection__rendered {
15
+ line-height: 1.5; color: var(--color-text-body); padding-left: 12px;
16
+ }
17
+ .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable {
18
+ background-color: var(--color-primary);
19
+ }
20
+ .select2-dropdown { border-color: var(--color-border-input); border-radius: var(--radius-md); box-shadow: var(--shadow-card); }
21
+ .select2-search--dropdown .select2-search__field { border-color: var(--color-border-input); border-radius: var(--radius-sm); padding: 6px 10px; }
22
+
23
+ .drop-zone {
24
+ border: 2px dashed var(--color-border-input);
25
+ border-radius: var(--radius-lg);
26
+ padding: 2.5rem;
27
+ text-align: center;
28
+ cursor: pointer;
29
+ transition: border-color 0.2s, background 0.2s;
30
+ background: var(--color-bg-input);
31
+ position: relative;
32
+ }
33
+ .drop-zone:hover, .drop-zone.drag-over {
34
+ border-color: var(--color-primary);
35
+ background: var(--color-primary-hover);
36
+ }
37
+ .drop-zone input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
38
+
39
+ .img-preview-wrap {
40
+ border-radius: var(--radius-lg);
41
+ overflow: hidden;
42
+ background: #f8fafc;
43
+ border: 1px solid var(--color-border);
44
+ max-height: 400px;
45
+ display: flex; align-items: center; justify-content: center;
46
+ }
47
+ .img-preview-wrap img { max-width: 100%; max-height: 400px; object-fit: contain; display: block; }
48
+
49
+ .result-box {
50
+ background: var(--color-bg-input);
51
+ border: 1px solid var(--color-border);
52
+ border-radius: var(--radius-lg);
53
+ padding: 1.5rem;
54
+ min-height: 160px;
55
+ font-size: 0.875rem;
56
+ line-height: 1.8;
57
+ white-space: pre-wrap;
58
+ word-break: break-word;
59
+ }
60
+ .mode-btn {
61
+ border: 1.5px solid var(--color-border);
62
+ border-radius: var(--radius-sm);
63
+ padding: 0.45rem 1rem;
64
+ font-size: 0.82rem;
65
+ font-weight: 600;
66
+ color: var(--color-text-muted);
67
+ background: #fff;
68
+ cursor: pointer;
69
+ transition: all 0.15s;
70
+ }
71
+ .mode-btn.active {
72
+ background: var(--color-primary-light);
73
+ color: var(--color-primary);
74
+ border-color: var(--color-primary);
75
+ }
76
+ </style>
77
+ {% endblock %}
78
+
79
+ {% block content %}
80
+ <div id="loaderOverlay" class="loader-overlay">
81
+ <div class="spinner-pro"></div>
82
+ <p class="mt-3 fw-bold" style="color: var(--color-primary)">Đang đọc và dịch ảnh...</p>
83
+ </div>
84
+
85
+ <div class="card card-custom">
86
+ <div class="card-header bg-white border-0 pt-4 text-center">
87
+ <h3 class="fw-bold" style="color: var(--color-primary)">AI TRANSLATOR PRO</h3>
88
+ <p class="text-muted">Dịch văn bản, tài liệu & ảnh bằng AI</p>
89
+ </div>
90
+
91
+ <div class="card-body p-4">
92
+
93
+ <!-- Tab pills -->
94
+ <ul class="nav nav-pills mb-4 justify-content-center">
95
+ <li class="nav-item"><a class="nav-link" href="/translator/text">Văn bản</a></li>
96
+ <li class="nav-item"><a class="nav-link" href="/translator/file">Tài liệu</a></li>
97
+ <li class="nav-item"><a class="nav-link active" href="/translator/image"><i class="fas fa-image me-1"></i>Dịch ảnh</a></li>
98
+ </ul>
99
+
100
+ <!-- Upload ảnh -->
101
+ <div class="drop-zone mb-4" id="dropZone" onclick="document.getElementById('imageInput').click()">
102
+ <input type="file" id="imageInput" accept="image/jpeg,image/png,image/webp,image/gif">
103
+ <i class="fas fa-image fa-2x text-muted mb-2 d-block"></i>
104
+ <div class="fw-semibold text-muted">Kéo thả ảnh vào đây hoặc click để chọn</div>
105
+ <div class="small text-muted mt-1">Hỗ trợ JPG, PNG, WEBP, GIF — tối đa 16MB</div>
106
+ <div class="small text-muted mt-1" style="opacity:0.6"><i class="fas fa-paste me-1"></i>Hoặc nhấn <kbd>Ctrl+V</kbd> để dán ảnh từ clipboard</div>
107
+ <div id="uploadedFileName" class="mt-2 fw-bold d-none" style="color: var(--color-primary)"></div>
108
+ </div>
109
+
110
+ <!-- Preview ảnh -->
111
+ <div id="imgPreviewArea" class="mb-4 d-none">
112
+ <div class="img-preview-wrap">
113
+ <img id="imgPreview" src="" alt="Preview">
114
+ </div>
115
+ </div>
116
+
117
+ <!-- Settings -->
118
+ <div class="row g-3 mb-4">
119
+ <div class="col-md-4">
120
+ <label class="form-label">Ngôn ngữ đích</label>
121
+ <select id="targetLang" class="form-select">
122
+ <option value="vi" selected>Vietnamese (Tiếng Việt)</option>
123
+ </select>
124
+ </div>
125
+ <div class="col-md-4">
126
+ <label class="form-label">Mô hình Gemini</label>
127
+ <select id="geminiModel" class="form-select">
128
+ <option value="">Đang tải...</option>
129
+ </select>
130
+ <div id="modelQuota" class="mt-1 small text-muted"></div>
131
+ </div>
132
+ <div class="col-md-4">
133
+ <label class="form-label">Chế độ</label>
134
+ <div class="d-flex gap-2 mt-1">
135
+ <button class="mode-btn active" data-mode="translate">
136
+ <i class="fas fa-language me-1"></i> OCR + Dịch
137
+ </button>
138
+ <button class="mode-btn" data-mode="ocr_only">
139
+ <i class="fas fa-eye me-1"></i> Chỉ đọc
140
+ </button>
141
+ </div>
142
+ </div>
143
+ </div>
144
+
145
+ <!-- Action button -->
146
+ <button class="btn-primary-custom mb-4" id="translateBtn" disabled>
147
+ <i class="fas fa-magic me-2"></i> Xử lý ảnh
148
+ </button>
149
+
150
+ <!-- Error -->
151
+ <div id="errorArea" class="mb-3 d-none">
152
+ <div class="alert alert-danger d-flex align-items-center gap-2 mb-0">
153
+ <i class="fas fa-exclamation-circle"></i>
154
+ <span id="errorMsg"></span>
155
+ </div>
156
+ </div>
157
+
158
+ <!-- Result -->
159
+ <div id="resultSection" class="d-none">
160
+ <hr class="my-3">
161
+ <div class="d-flex justify-content-between align-items-center mb-2">
162
+ <div class="fw-semibold">
163
+ <i class="fas fa-check-circle text-success me-1"></i> Kết quả
164
+ </div>
165
+ <button class="btn btn-sm btn-outline-secondary" onclick="copyResult()">
166
+ <i class="fas fa-copy me-1"></i> Sao chép
167
+ </button>
168
+ </div>
169
+ <div class="result-box" id="resultBox"></div>
170
+ </div>
171
+
172
+ </div>
173
+ </div>
174
+ {% endblock %}
175
+
176
+ {% block scripts %}
177
+ <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
178
+ <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
179
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
180
+ <script>
181
+ let selectedFile = null;
182
+ let selectedMode = 'translate';
183
+ let geminiModelList = [];
184
+
185
+ // Load languages
186
+ async function loadLanguages() {
187
+ try {
188
+ const res = await fetch('/api/languages');
189
+ const langs = await res.json();
190
+ const sel = document.getElementById('targetLang');
191
+ sel.innerHTML = '';
192
+ langs.filter(l => !l.source_only).forEach(l => {
193
+ const opt = document.createElement('option');
194
+ opt.value = l.code;
195
+ opt.textContent = l.name;
196
+ if (l.code === 'vi') opt.selected = true;
197
+ sel.appendChild(opt);
198
+ });
199
+ $('#targetLang').select2({ placeholder: 'Chọn ngôn ngữ đích', width: '100%' });
200
+ } catch(e) { console.error(e); }
201
+ }
202
+
203
+ // Load Gemini models
204
+ async function loadGeminiModels() {
205
+ try {
206
+ const res = await fetch('/api/gemini-models');
207
+ geminiModelList = await res.json();
208
+ const sel = document.getElementById('geminiModel');
209
+ sel.innerHTML = '';
210
+ geminiModelList.forEach(m => {
211
+ const opt = document.createElement('option');
212
+ opt.value = m.id;
213
+ const quota = m.remaining > 0 ? `✓ Còn ${m.remaining.toLocaleString()}/${m.rpd}` : `✗ Hết`;
214
+ opt.textContent = `${m.name} — ${quota}`;
215
+ if (m.remaining === 0) opt.disabled = true;
216
+ sel.appendChild(opt);
217
+ });
218
+ updateModelQuota();
219
+ } catch(e) { console.error(e); }
220
+ }
221
+
222
+ function updateModelQuota() {
223
+ const sel = document.getElementById('geminiModel');
224
+ const chosen = geminiModelList.find(m => m.id === sel.value);
225
+ const el = document.getElementById('modelQuota');
226
+ if (chosen) {
227
+ el.innerHTML = chosen.remaining > 0
228
+ ? `<span style="color:var(--color-primary)"><i class="fas fa-check-circle"></i> Còn <strong>${chosen.remaining.toLocaleString()}</strong> request hôm nay</span>`
229
+ : `<span style="color:#dc3545"><i class="fas fa-exclamation-triangle"></i> Đã hết quota</span>`;
230
+ }
231
+ }
232
+
233
+ document.getElementById('geminiModel').addEventListener('change', updateModelQuota);
234
+ loadLanguages();
235
+ loadGeminiModels();
236
+
237
+ // Mode buttons
238
+ document.querySelectorAll('.mode-btn').forEach(btn => {
239
+ btn.addEventListener('click', () => {
240
+ document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
241
+ btn.classList.add('active');
242
+ selectedMode = btn.dataset.mode;
243
+ });
244
+ });
245
+
246
+ // Drag & drop
247
+ const dropZone = document.getElementById('dropZone');
248
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
249
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
250
+ dropZone.addEventListener('drop', e => {
251
+ e.preventDefault();
252
+ dropZone.classList.remove('drag-over');
253
+ if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
254
+ });
255
+ document.getElementById('imageInput').addEventListener('change', e => {
256
+ if (e.target.files[0]) handleFile(e.target.files[0]);
257
+ });
258
+
259
+ function handleFile(file) {
260
+ selectedFile = file;
261
+ document.getElementById('uploadedFileName').textContent = `✓ ${file.name}`;
262
+ document.getElementById('uploadedFileName').classList.remove('d-none');
263
+ document.getElementById('translateBtn').disabled = false;
264
+ document.getElementById('resultSection').classList.add('d-none');
265
+ document.getElementById('errorArea').classList.add('d-none');
266
+
267
+ const reader = new FileReader();
268
+ reader.onload = e => {
269
+ document.getElementById('imgPreview').src = e.target.result;
270
+ document.getElementById('imgPreviewArea').classList.remove('d-none');
271
+ };
272
+ reader.readAsDataURL(file);
273
+ }
274
+
275
+ // Process
276
+ document.getElementById('translateBtn').addEventListener('click', async () => {
277
+ if (!selectedFile) return;
278
+ document.getElementById('loaderOverlay').style.display = 'flex';
279
+ document.getElementById('errorArea').classList.add('d-none');
280
+ document.getElementById('resultSection').classList.add('d-none');
281
+
282
+ const formData = new FormData();
283
+ formData.append('image', selectedFile);
284
+ formData.append('target_lang', $('#targetLang').val());
285
+ formData.append('gemini_model', document.getElementById('geminiModel').value);
286
+ formData.append('mode', selectedMode);
287
+
288
+ try {
289
+ const res = await fetch('/translate-image', { method: 'POST', body: formData });
290
+ const data = await res.json();
291
+ if (data.error) {
292
+ document.getElementById('errorMsg').textContent = data.error;
293
+ document.getElementById('errorArea').classList.remove('d-none');
294
+ } else {
295
+ document.getElementById('resultBox').innerHTML = marked.parse(data.result);
296
+ document.getElementById('resultSection').classList.remove('d-none');
297
+ await loadGeminiModels();
298
+ }
299
+ } catch(e) {
300
+ document.getElementById('errorMsg').textContent = 'Lỗi kết nối. Vui lòng thử lại.';
301
+ document.getElementById('errorArea').classList.remove('d-none');
302
+ } finally {
303
+ document.getElementById('loaderOverlay').style.display = 'none';
304
+ }
305
+ });
306
+
307
+ function copyResult() {
308
+ navigator.clipboard.writeText(document.getElementById('resultBox').innerText).then(() => {
309
+ const btn = event.target.closest('button');
310
+ btn.innerHTML = '<i class="fas fa-check me-1"></i> Đã sao chép';
311
+ setTimeout(() => btn.innerHTML = '<i class="fas fa-copy me-1"></i> Sao chép', 2000);
312
+ });
313
+ }
314
+ </script>
315
+ {% endblock %}
templates/tools/translator/index.html ADDED
@@ -0,0 +1,373 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}AI Translator Pro - Pum's Toolkit{% endblock %}
4
+
5
+ {% block styles %}
6
+ <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
7
+ <style>
8
+ .select2-container .select2-selection--single {
9
+ height: calc(1.5em + 0.75rem + 2px);
10
+ border-color: var(--color-border-input);
11
+ border-radius: var(--radius-md);
12
+ display: flex;
13
+ align-items: center;
14
+ }
15
+ .select2-container--default .select2-selection--single .select2-selection__rendered {
16
+ line-height: 1.5;
17
+ color: var(--color-text-body);
18
+ padding-left: 12px;
19
+ }
20
+ .select2-container--default .select2-selection--single .select2-selection__arrow {
21
+ height: 100%;
22
+ }
23
+ .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable {
24
+ background-color: var(--color-primary);
25
+ }
26
+ .select2-dropdown {
27
+ border-color: var(--color-border-input);
28
+ border-radius: var(--radius-md);
29
+ box-shadow: var(--shadow-card);
30
+ }
31
+ .select2-search--dropdown .select2-search__field {
32
+ border-color: var(--color-border-input);
33
+ border-radius: var(--radius-sm);
34
+ padding: 6px 10px;
35
+ }
36
+ </style>
37
+ {% endblock %}
38
+
39
+ {% block content %}
40
+ <div id="loaderOverlay" class="loader-overlay">
41
+ <div class="spinner-pro"></div>
42
+ <p class="mt-3 fw-bold" style="color: var(--color-primary)">Đang xử lý bản dịch...</p>
43
+ </div>
44
+
45
+ <div class="card card-custom">
46
+ <div class="card-header bg-white border-0 pt-4 text-center">
47
+ <h3 class="fw-bold" style="color: var(--color-primary)">AI TRANSLATOR PRO</h3>
48
+ <p class="text-muted">Dịch văn bản, tài liệu & ảnh bằng AI</p>
49
+ </div>
50
+
51
+ <div class="card-body p-4">
52
+ <ul class="nav nav-pills mb-4 justify-content-center" id="pills-tab">
53
+ <li class="nav-item">
54
+ <a class="nav-link {% if active_tab == 'text' %}active{% endif %}" href="/translator/text">Văn bản</a>
55
+ </li>
56
+ <li class="nav-item">
57
+ <a class="nav-link {% if active_tab == 'file' %}active{% endif %}" href="/translator/file">Tài liệu</a>
58
+ </li>
59
+ <li class="nav-item">
60
+ <a class="nav-link" href="/translator/image"><i class="fas fa-image me-1"></i>Dịch ảnh</a>
61
+ </li>
62
+ </ul>
63
+
64
+ <div class="row g-3 mb-4">
65
+ <div class="col-md-4">
66
+ <label class="form-label">Ngôn ngữ nguồn</label>
67
+ <select id="sourceLang" class="form-select">
68
+ <option value="auto">Đang tải...</option>
69
+ </select>
70
+ </div>
71
+ <div class="col-md-4">
72
+ <label class="form-label">Ngôn ngữ đích</label>
73
+ <select id="targetLang" class="form-select">
74
+ <option value="vi">Đang tải...</option>
75
+ </select>
76
+ </div>
77
+ <div class="col-md-4">
78
+ <label class="form-label">Công cụ</label>
79
+ <select id="engine" class="form-select" onchange="toggleGeminiStyles()">
80
+ <option value="google">Google Translate</option>
81
+ <option value="gemini">Gemini AI</option>
82
+ </select>
83
+ </div>
84
+ </div>
85
+
86
+ <div id="geminiStyles" class="mb-4" style="display:none">
87
+ <div class="row g-3">
88
+ <div class="col-md-6">
89
+ <label class="form-label" style="color: var(--color-primary)">Mô hình Gemini</label>
90
+ <select id="geminiModel" class="form-select" style="border-color: var(--color-primary)">
91
+ <option value="">Đang tải...</option>
92
+ </select>
93
+ <div id="modelQuota" class="mt-1 small text-muted"></div>
94
+ </div>
95
+ <div class="col-md-6">
96
+ <label class="form-label" style="color: var(--color-primary)">Phong cách dịch</label>
97
+ <select id="translationStyle" class="form-select" style="border-color: var(--color-primary)">
98
+ <option value="default">Mặc định</option>
99
+ <option value="formal">Trang trọng</option>
100
+ <option value="casual">Thân mật</option>
101
+ <option value="creative">Sáng tạo</option>
102
+ <option value="technical">Kỹ thuật</option>
103
+ </select>
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <div class="tab-content">
109
+ <div class="tab-pane fade {% if active_tab == 'text' %}show active{% endif %}" id="text-mode">
110
+ <div class="row g-3">
111
+ <div class="col-md-6">
112
+ <textarea id="inputText" class="form-control" rows="8" placeholder="Nhập văn bản..."></textarea>
113
+ </div>
114
+ <div class="col-md-6">
115
+ <textarea id="outputText" class="form-control" rows="8" placeholder="Bản dịch..." readonly></textarea>
116
+ </div>
117
+ </div>
118
+ </div>
119
+ <div class="tab-pane fade {% if active_tab == 'file' %}show active{% endif %}" id="file-mode">
120
+ <div class="file-upload-wrapper" id="dropZone" onclick="document.getElementById('fileInput').click()">
121
+ <i class="fas fa-file-upload fa-3x mb-3 text-muted"></i>
122
+ <h5>Chọn file (.txt, .docx, .pdf)</h5>
123
+ <div class="small text-muted mt-1" style="opacity:0.6"><i class="fas fa-paste me-1"></i>Hoặc nhấn <kbd>Ctrl+V</kbd> để dán file</div>
124
+ <input type="file" id="fileInput" class="d-none" accept=".txt,.docx,.pdf">
125
+ <div id="fileNameDisplay" class="mt-2 fw-bold" style="color: var(--color-primary)"></div>
126
+ </div>
127
+
128
+ <!-- Preview trước/sau dịch -->
129
+ <div id="previewArea" class="d-none mt-4">
130
+ <div class="row g-3">
131
+ <div class="col-md-6">
132
+ <label class="form-label d-flex justify-content-between">
133
+ <span><i class="fas fa-file-alt me-1"></i> Nội dung gốc <span class="badge bg-secondary" id="previewCharCount"></span></span>
134
+ </label>
135
+ <textarea id="previewOriginal" class="form-control" rows="10" readonly
136
+ style="font-size: 0.82rem; background: var(--color-bg-input);"></textarea>
137
+ </div>
138
+ <div class="col-md-6">
139
+ <label class="form-label">
140
+ <i class="fas fa-language me-1"></i> Bản dịch preview
141
+ <span id="previewTranslatedBadge" class="badge bg-secondary ms-1 d-none">1000 ký tự đầu</span>
142
+ </label>
143
+ <textarea id="previewTranslated" class="form-control" rows="10" readonly
144
+ placeholder="Bản dịch sẽ hiển thị sau khi nhấn Dịch Ngay..."
145
+ style="font-size: 0.82rem; background: var(--color-bg-input);"></textarea>
146
+ </div>
147
+ </div>
148
+ <p class="text-muted small mt-2 mb-0">
149
+ <i class="fas fa-info-circle"></i>
150
+ Chỉ hiển thị 1000 ký tự đầu để review. File dịch đầy đủ sẽ có ở phần tải về bên dưới.
151
+ </p>
152
+ </div>
153
+ </div>
154
+ </div>
155
+
156
+ <button id="translateBtn" class="btn-primary-custom mt-4">DỊCH NGAY</button>
157
+
158
+ <div id="errorArea" class="mt-3 d-none">
159
+ <div class="alert alert-danger d-flex align-items-center gap-2 mb-0">
160
+ <i class="fas fa-exclamation-circle"></i>
161
+ <span id="errorMsg"></span>
162
+ </div>
163
+ </div>
164
+
165
+ <div id="downloadArea" class="mt-3 text-center d-none">
166
+ <div class="alert alert-success">
167
+ Dịch xong!
168
+ <a id="downloadLink" href="#" class="btn btn-sm btn-success ms-2">Tải về file</a>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ {% endblock %}
174
+
175
+ {% block scripts %}
176
+ <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
177
+ <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
178
+ <script>
179
+ // Load danh sách ngôn ngữ
180
+ async function loadLanguages() {
181
+ try {
182
+ const res = await fetch('/api/languages');
183
+ const langs = await res.json();
184
+
185
+ const sourceSel = document.getElementById('sourceLang');
186
+ const targetSel = document.getElementById('targetLang');
187
+ sourceSel.innerHTML = '';
188
+ targetSel.innerHTML = '';
189
+
190
+ langs.forEach(l => {
191
+ // Source: thêm tất cả
192
+ const srcOpt = document.createElement('option');
193
+ srcOpt.value = l.code;
194
+ srcOpt.textContent = l.name;
195
+ if (l.code === 'auto') srcOpt.selected = true;
196
+ sourceSel.appendChild(srcOpt);
197
+
198
+ // Target: bỏ "auto"
199
+ if (!l.source_only) {
200
+ const tgtOpt = document.createElement('option');
201
+ tgtOpt.value = l.code;
202
+ tgtOpt.textContent = l.name;
203
+ if (l.code === 'vi') tgtOpt.selected = true;
204
+ targetSel.appendChild(tgtOpt);
205
+ }
206
+ });
207
+
208
+ // Khởi tạo select2 sau khi đã có options
209
+ $('#sourceLang').select2({ placeholder: 'Chọn ngôn ngữ nguồn', width: '100%' });
210
+ $('#targetLang').select2({ placeholder: 'Chọn ngôn ngữ đích', width: '100%' });
211
+ } catch(e) {
212
+ console.error('Không load được danh sách ngôn ngữ:', e);
213
+ }
214
+ }
215
+ loadLanguages();
216
+
217
+ function toggleGeminiStyles() {
218
+ const isGemini = document.getElementById('engine').value === 'gemini';
219
+ document.getElementById('geminiStyles').style.display = isGemini ? 'block' : 'none';
220
+ }
221
+
222
+ // Load danh sách model Gemini
223
+ let geminiModelList = [];
224
+ async function loadGeminiModels() {
225
+ try {
226
+ const res = await fetch('/api/gemini-models');
227
+ geminiModelList = await res.json();
228
+ const sel = document.getElementById('geminiModel');
229
+ sel.innerHTML = '';
230
+ geminiModelList.forEach(m => {
231
+ const opt = document.createElement('option');
232
+ opt.value = m.id;
233
+ const quotaText = m.remaining > 0
234
+ ? `✓ Còn ${m.remaining.toLocaleString()}/${m.rpd.toLocaleString()}`
235
+ : `✗ Hết (${m.used}/${m.rpd})`;
236
+ opt.textContent = `${m.name} — ${quotaText}`;
237
+ if (m.remaining === 0) opt.disabled = true;
238
+ sel.appendChild(opt);
239
+ });
240
+ updateModelQuota();
241
+ } catch(e) {
242
+ console.error('Không load được model list:', e);
243
+ }
244
+ }
245
+
246
+ function updateModelQuota() {
247
+ const sel = document.getElementById('geminiModel');
248
+ const chosen = geminiModelList.find(m => m.id === sel.value);
249
+ const el = document.getElementById('modelQuota');
250
+ if (chosen) {
251
+ const status = chosen.remaining > 0
252
+ ? `<span style="color: var(--color-primary)"><i class="fas fa-check-circle"></i> Còn <strong>${chosen.remaining.toLocaleString()}</strong> request hôm nay</span>`
253
+ : `<span style="color: #dc3545"><i class="fas fa-exclamation-triangle"></i> Đã hết quota (${chosen.used}/${chosen.rpd})</span>`;
254
+ el.innerHTML = status;
255
+ }
256
+ }
257
+
258
+ document.getElementById('geminiModel')?.addEventListener('change', updateModelQuota);
259
+ loadGeminiModels();
260
+
261
+ function showError(msg) {
262
+ const area = document.getElementById('errorArea');
263
+ document.getElementById('errorMsg').textContent = msg;
264
+ area.classList.remove('d-none');
265
+ }
266
+
267
+ function clearError() {
268
+ document.getElementById('errorArea').classList.add('d-none');
269
+ }
270
+
271
+ const fileInput = document.getElementById('fileInput');
272
+ if (fileInput) {
273
+ fileInput.addEventListener('change', async e => {
274
+ const file = e.target.files[0];
275
+ if (!file) return;
276
+ document.getElementById('fileNameDisplay').textContent = `Đã chọn: ${file.name}`;
277
+
278
+ // Gọi preview file
279
+ const fd = new FormData();
280
+ fd.append('file', file);
281
+ try {
282
+ const res = await fetch('/preview-file', { method: 'POST', body: fd });
283
+ const data = await res.json();
284
+ if (data.error) { showError(data.error); return; }
285
+
286
+ document.getElementById('previewOriginal').value = data.preview + (data.truncated ? '\n...' : '');
287
+ document.getElementById('previewCharCount').textContent =
288
+ data.truncated ? `1.000 / ${data.total_chars.toLocaleString()} ký tự` : `${data.total_chars.toLocaleString()} ký tự`;
289
+ document.getElementById('previewTranslated').value = '';
290
+ document.getElementById('previewTranslatedBadge').classList.add('d-none');
291
+ document.getElementById('previewArea').classList.remove('d-none');
292
+ } catch(err) {
293
+ showError('Không đọc được file để preview.');
294
+ }
295
+ });
296
+ }
297
+
298
+ document.getElementById('translateBtn').addEventListener('click', async () => {
299
+ const isFileMode = window.location.pathname === '/translator/file';
300
+ const loader = document.getElementById('loaderOverlay');
301
+ loader.style.display = 'flex';
302
+ clearError();
303
+ try {
304
+ if (!isFileMode) {
305
+ const res = await fetch('/translate', {
306
+ method: 'POST',
307
+ headers: { 'Content-Type': 'application/json' },
308
+ body: JSON.stringify({
309
+ text: document.getElementById('inputText').value,
310
+ target_lang: $('#targetLang').val(),
311
+ engine: document.getElementById('engine').value,
312
+ style: document.getElementById('translationStyle').value,
313
+ gemini_model: document.getElementById('geminiModel').value
314
+ })
315
+ });
316
+ const data = await res.json();
317
+ if (data.error) showError(data.error);
318
+ else {
319
+ document.getElementById('outputText').value = data.translated_text;
320
+ if (document.getElementById('engine').value === 'gemini') {
321
+ await loadGeminiModels(); // Cập nhật quota
322
+ }
323
+ }
324
+ } else {
325
+ const formData = new FormData();
326
+ formData.append('file', document.getElementById('fileInput').files[0]);
327
+ formData.append('target_lang', $('#targetLang').val());
328
+ formData.append('engine', document.getElementById('engine').value);
329
+ formData.append('style', document.getElementById('translationStyle').value);
330
+ formData.append('gemini_model', document.getElementById('geminiModel').value);
331
+ const res = await fetch('/translate', { method: 'POST', body: formData });
332
+ const data = await res.json();
333
+ if (data.error) showError(data.error);
334
+ else {
335
+ document.getElementById('downloadLink').href = data.download_url;
336
+ document.getElementById('downloadArea').classList.remove('d-none');
337
+
338
+ if (document.getElementById('engine').value === 'gemini') {
339
+ await loadGeminiModels(); // Cập nhật quota
340
+ }
341
+
342
+ // Dịch 1000 ký tự đầu để preview
343
+ const previewText = document.getElementById('previewOriginal').value.replace(/\n\.\.\.$/, '');
344
+ if (previewText.trim()) {
345
+ try {
346
+ const pvRes = await fetch('/translate', {
347
+ method: 'POST',
348
+ headers: { 'Content-Type': 'application/json' },
349
+ body: JSON.stringify({
350
+ text: previewText,
351
+ target_lang: $('#targetLang').val(),
352
+ engine: document.getElementById('engine').value,
353
+ style: document.getElementById('translationStyle').value,
354
+ gemini_model: document.getElementById('geminiModel').value
355
+ })
356
+ });
357
+ const pvData = await pvRes.json();
358
+ if (!pvData.error) {
359
+ document.getElementById('previewTranslated').value = pvData.translated_text;
360
+ document.getElementById('previewTranslatedBadge').classList.remove('d-none');
361
+ }
362
+ } catch(e) { /* preview dịch lỗi thì bỏ qua */ }
363
+ }
364
+ }
365
+ }
366
+ } catch (e) {
367
+ showError('Lỗi kết nối. Vui lòng thử lại.');
368
+ } finally {
369
+ loader.style.display = 'none';
370
+ }
371
+ });
372
+ </script>
373
+ {% endblock %}
templates/tools/tts/edge.html ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Text to Speech - Pum's Toolkit{% endblock %}
4
+
5
+ {% block styles %}
6
+ <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
7
+ <style>
8
+ .select2-container .select2-selection--single { height: calc(1.5em + 0.75rem + 2px); border-color: var(--color-border-input); border-radius: var(--radius-md); display: flex; align-items: center; }
9
+ .select2-container--default .select2-selection--single .select2-selection__rendered { line-height: 1.5; color: var(--color-text-body); padding-left: 12px; }
10
+ .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { background-color: var(--color-primary); }
11
+ .select2-dropdown { border-color: var(--color-border-input); border-radius: var(--radius-md); box-shadow: var(--shadow-card); }
12
+ .select2-search--dropdown .select2-search__field { border-color: var(--color-border-input); border-radius: var(--radius-sm); padding: 6px 10px; }
13
+ .char-counter { font-size: 0.78rem; color: var(--color-text-muted); }
14
+ .char-counter.warn { color: #f59e0b; }
15
+ .char-counter.over { color: #ef4444; }
16
+ .audio-player-wrap { background: var(--color-bg-input); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 1.5rem; }
17
+ audio { width: 100%; border-radius: 8px; }
18
+ .range-wrap { display: flex; align-items: center; gap: 10px; }
19
+ .range-wrap input[type=range] { flex: 1; accent-color: var(--color-primary); }
20
+ .range-val { min-width: 52px; text-align: right; font-size: 0.82rem; font-weight: 600; color: var(--color-primary); }
21
+ .voice-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 20px; font-size: 0.7rem; font-weight: 600; }
22
+ .badge-female { background: #fce7f3; color: #be185d; }
23
+ .badge-male { background: #dbeafe; color: #1d4ed8; }
24
+ </style>
25
+ {% endblock %}
26
+
27
+ {% block content %}
28
+ <div id="loaderOverlay" class="loader-overlay">
29
+ <div class="spinner-pro"></div>
30
+ <p class="mt-3 fw-bold" style="color: var(--color-primary)">Đang tạo giọng nói...</p>
31
+ </div>
32
+
33
+ <div class="card card-custom">
34
+ <!-- Header chung -->
35
+ <div class="card-header bg-white border-0 pt-4 text-center">
36
+ <h3 class="fw-bold" style="color: var(--color-primary)">TEXT TO SPEECH</h3>
37
+ <p class="text-muted">Chuyển văn bản thành giọng nói tự nhiên</p>
38
+ <ul class="nav nav-pills justify-content-center mb-0">
39
+ <li class="nav-item"><a class="nav-link" href="/tts/vieneu"><i class="fas fa-dragon me-1"></i> VieNeu-TTS</a></li>
40
+ <li class="nav-item"><a class="nav-link" href="/tts/gtts"><i class="fab fa-google me-1"></i> Google TTS</a></li>
41
+ <li class="nav-item"><a class="nav-link active" href="/tts/edge"><i class="fab fa-microsoft me-1"></i> Edge TTS</a></li>
42
+ </ul>
43
+ </div>
44
+
45
+ <div class="card-body p-4">
46
+
47
+ <!-- Lọc ngôn ngữ + chọn giọng -->
48
+ <div class="row g-3 mb-4">
49
+ <div class="col-md-4">
50
+ <label class="form-label">Ngôn ngữ</label>
51
+ <select id="langFilter" class="form-select">
52
+ <option value="">Đang tải...</option>
53
+ </select>
54
+ </div>
55
+ <div class="col-md-8">
56
+ <label class="form-label">Giọng đọc <span id="voiceCount" class="text-muted fw-normal" style="font-size:0.78rem"></span></label>
57
+ <select id="voiceSelect" class="form-select">
58
+ <option value="">Chọn ngôn ngữ trước...</option>
59
+ </select>
60
+ </div>
61
+ </div>
62
+
63
+ <!-- Điều chỉnh tốc độ & cao độ -->
64
+ <div class="row g-3 mb-4">
65
+ <div class="col-md-6">
66
+ <label class="form-label">Tốc độ</label>
67
+ <div class="range-wrap">
68
+ <input type="range" id="rateRange" min="-50" max="100" step="5" value="0">
69
+ <span class="range-val" id="rateVal">+0%</span>
70
+ </div>
71
+ </div>
72
+ <div class="col-md-6">
73
+ <label class="form-label">Cao độ (Pitch)</label>
74
+ <div class="range-wrap">
75
+ <input type="range" id="pitchRange" min="-50" max="50" step="5" value="0">
76
+ <span class="range-val" id="pitchVal">+0Hz</span>
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ <!-- Văn bản -->
82
+ <div class="mb-4">
83
+ <label class="form-label d-flex justify-content-between">
84
+ <span>Văn bản <small class="text-muted fw-normal">(322 giọng neural, 70+ ngôn ngữ)</small></span>
85
+ <span class="char-counter" id="charCounter">0 / 10.000</span>
86
+ </label>
87
+ <textarea id="ttsText" class="form-control" rows="7"
88
+ placeholder="Nhập văn bản bất kỳ ngôn ngữ nào..."></textarea>
89
+ </div>
90
+
91
+ <button class="btn-primary-custom mb-4" id="generateBtn" disabled>
92
+ <i class="fas fa-play me-2"></i> Tạo giọng nói
93
+ </button>
94
+
95
+ <div id="errorArea" class="mb-3 d-none">
96
+ <div class="alert alert-danger d-flex align-items-center gap-2 mb-0">
97
+ <i class="fas fa-exclamation-circle"></i>
98
+ <span id="errorMsg"></span>
99
+ </div>
100
+ </div>
101
+
102
+ <div id="audioSection" class="d-none">
103
+ <hr class="my-3">
104
+ <div class="audio-player-wrap">
105
+ <div class="d-flex justify-content-between align-items-center mb-3">
106
+ <div class="fw-semibold"><i class="fas fa-check-circle text-success me-1"></i> Hoàn thành</div>
107
+ <a id="downloadLink" href="#" class="btn btn-success btn-sm px-3">
108
+ <i class="fas fa-download me-1"></i> Tải về MP3
109
+ </a>
110
+ </div>
111
+ <audio id="audioPlayer" controls></audio>
112
+ </div>
113
+ </div>
114
+
115
+ </div>
116
+ </div>
117
+ {% endblock %}
118
+
119
+ {% block scripts %}
120
+ <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
121
+ <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
122
+ <script>
123
+ let allVoices = [];
124
+
125
+ // Tên ngôn ngữ hiển thị
126
+ const langNames = {
127
+ 'af': 'Afrikaans', 'sq': 'Albanian', 'am': 'Amharic', 'ar': 'Arabic (العربية)',
128
+ 'az': 'Azerbaijani', 'bn': 'Bengali', 'bs': 'Bosnian', 'bg': 'Bulgarian',
129
+ 'my': 'Burmese', 'ca': 'Catalan', 'zh': 'Chinese (中文)', 'hr': 'Croatian',
130
+ 'cs': 'Czech', 'da': 'Danish', 'nl': 'Dutch', 'en': 'English',
131
+ 'et': 'Estonian', 'fi': 'Finnish', 'fr': 'French', 'gl': 'Galician',
132
+ 'ka': 'Georgian', 'de': 'German', 'el': 'Greek', 'gu': 'Gujarati',
133
+ 'he': 'Hebrew', 'hi': 'Hindi (हिन्दी)', 'hu': 'Hungarian', 'is': 'Icelandic',
134
+ 'id': 'Indonesian', 'ga': 'Irish', 'it': 'Italian', 'ja': 'Japanese (日本語)',
135
+ 'jv': 'Javanese', 'kn': 'Kannada', 'kk': 'Kazakh', 'km': 'Khmer',
136
+ 'ko': 'Korean (한국어)', 'lo': 'Lao', 'lv': 'Latvian', 'lt': 'Lithuanian',
137
+ 'mk': 'Macedonian', 'ms': 'Malay', 'ml': 'Malayalam', 'mt': 'Maltese',
138
+ 'mr': 'Marathi', 'mn': 'Mongolian', 'ne': 'Nepali', 'nb': 'Norwegian',
139
+ 'or': 'Odia', 'ps': 'Pashto', 'fa': 'Persian (فارسی)', 'pl': 'Polish',
140
+ 'pt': 'Portuguese', 'pa': 'Punjabi', 'ro': 'Romanian', 'ru': 'Russian (Русский)',
141
+ 'sr': 'Serbian', 'si': 'Sinhala', 'sk': 'Slovak', 'sl': 'Slovenian',
142
+ 'so': 'Somali', 'es': 'Spanish', 'su': 'Sundanese', 'sw': 'Swahili',
143
+ 'sv': 'Swedish', 'tl': 'Filipino', 'ta': 'Tamil', 'te': 'Telugu',
144
+ 'th': 'Thai (ภาษาไทย)', 'tr': 'Turkish', 'uk': 'Ukrainian', 'ur': 'Urdu',
145
+ 'uz': 'Uzbek', 'vi': 'Vietnamese (Tiếng Việt)', 'cy': 'Welsh', 'zu': 'Zulu'
146
+ };
147
+
148
+ async function loadVoices() {
149
+ try {
150
+ const res = await fetch('/api/edge-voices');
151
+ allVoices = await res.json();
152
+ if (allVoices.error) return;
153
+
154
+ // Build language list
155
+ const langs = [...new Set(allVoices.map(v => v.lang))].sort();
156
+ const langSel = document.getElementById('langFilter');
157
+ langSel.innerHTML = '<option value="">-- Chọn ngôn ngữ --</option>';
158
+ langs.forEach(code => {
159
+ const opt = document.createElement('option');
160
+ opt.value = code;
161
+ opt.textContent = langNames[code] || code.toUpperCase();
162
+ langSel.appendChild(opt);
163
+ });
164
+
165
+ $('#langFilter').select2({ placeholder: 'Tìm ngôn ngữ...', width: '100%' });
166
+ $('#langFilter').on('change', filterVoices);
167
+
168
+ // Default: tiếng Việt
169
+ $('#langFilter').val('vi').trigger('change');
170
+ } catch(e) { console.error(e); }
171
+ }
172
+
173
+ function filterVoices() {
174
+ const lang = document.getElementById('langFilter').value;
175
+ const filtered = lang ? allVoices.filter(v => v.lang === lang) : [];
176
+ const sel = document.getElementById('voiceSelect');
177
+ sel.innerHTML = '';
178
+
179
+ if (!lang) {
180
+ sel.innerHTML = '<option value="">Chọn ngôn ngữ trước...</option>';
181
+ document.getElementById('generateBtn').disabled = true;
182
+ document.getElementById('voiceCount').textContent = '';
183
+ return;
184
+ }
185
+
186
+ filtered.forEach(v => {
187
+ const opt = document.createElement('option');
188
+ opt.value = v.name;
189
+ const genderIcon = v.gender === 'Female' ? '♀' : '♂';
190
+ // Lấy tên ngắn gọn từ friendly name
191
+ const friendlyName = v.display.replace(/^Microsoft /, '').replace(/ Online \(Natural\).*$/, '');
192
+ opt.textContent = `${genderIcon} ${friendlyName}`;
193
+ sel.appendChild(opt);
194
+ });
195
+
196
+ // Select2 cho voice
197
+ if ($.fn.select2 && $(sel).data('select2')) $(sel).select2('destroy');
198
+ $(sel).select2({ placeholder: 'Chọn giọng...', width: '100%' });
199
+ document.getElementById('voiceCount').textContent = `(${filtered.length} giọng)`;
200
+ document.getElementById('generateBtn').disabled = filtered.length === 0;
201
+ }
202
+
203
+ // Rate & Pitch sliders
204
+ document.getElementById('rateRange').addEventListener('input', function() {
205
+ const v = parseInt(this.value);
206
+ document.getElementById('rateVal').textContent = (v >= 0 ? '+' : '') + v + '%';
207
+ });
208
+ document.getElementById('pitchRange').addEventListener('input', function() {
209
+ const v = parseInt(this.value);
210
+ document.getElementById('pitchVal').textContent = (v >= 0 ? '+' : '') + v + 'Hz';
211
+ });
212
+
213
+ // Char counter
214
+ document.getElementById('ttsText').addEventListener('input', function() {
215
+ const len = this.value.length;
216
+ const el = document.getElementById('charCounter');
217
+ el.textContent = `${len.toLocaleString()} / 10.000`;
218
+ el.className = 'char-counter' + (len>10000?' over':len>8500?' warn':'');
219
+ });
220
+
221
+ // Generate
222
+ document.getElementById('generateBtn').addEventListener('click', async () => {
223
+ const text = document.getElementById('ttsText').value.trim();
224
+ const voice = $('#voiceSelect').val();
225
+ if (!text) { showError('Vui lòng nhập văn bản'); return; }
226
+ if (!voice) { showError('Vui lòng chọn giọng đọc'); return; }
227
+ if (text.length > 10000) { showError(`Văn bản quá dài (${text.length}/10000)`); return; }
228
+
229
+ const rateVal = document.getElementById('rateVal').textContent;
230
+ const pitchVal = document.getElementById('pitchVal').textContent;
231
+
232
+ document.getElementById('loaderOverlay').style.display = 'flex';
233
+ document.getElementById('errorArea').classList.add('d-none');
234
+ document.getElementById('audioSection').classList.add('d-none');
235
+
236
+ const fd = new FormData();
237
+ fd.append('text', text);
238
+ fd.append('voice', voice);
239
+ fd.append('rate', rateVal);
240
+ fd.append('pitch', pitchVal);
241
+
242
+ try {
243
+ const res = await fetch('/tts/edge/generate', {method:'POST', body:fd});
244
+ const data = await res.json();
245
+ if (data.error) { showError(data.error); return; }
246
+ const p = document.getElementById('audioPlayer');
247
+ p.src = data.audio_url + '?t=' + Date.now();
248
+ p.load(); p.play();
249
+ document.getElementById('downloadLink').href = data.audio_url;
250
+ document.getElementById('downloadLink').download = data.filename;
251
+ document.getElementById('audioSection').classList.remove('d-none');
252
+ } catch(e) { showError('Lỗi kết nối. Vui lòng thử lại.'); }
253
+ finally { document.getElementById('loaderOverlay').style.display = 'none'; }
254
+ });
255
+
256
+ function showError(msg) {
257
+ document.getElementById('errorMsg').textContent = msg;
258
+ document.getElementById('errorArea').classList.remove('d-none');
259
+ }
260
+
261
+ loadVoices();
262
+ </script>
263
+ {% endblock %}
templates/tools/tts/gtts.html ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Text to Speech - Pum's Toolkit{% endblock %}
4
+
5
+ {% block styles %}
6
+ <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet">
7
+ <style>
8
+ .select2-container .select2-selection--single { height: calc(1.5em + 0.75rem + 2px); border-color: var(--color-border-input); border-radius: var(--radius-md); display: flex; align-items: center; }
9
+ .select2-container--default .select2-selection--single .select2-selection__rendered { line-height: 1.5; color: var(--color-text-body); padding-left: 12px; }
10
+ .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable { background-color: var(--color-primary); }
11
+ .select2-dropdown { border-color: var(--color-border-input); border-radius: var(--radius-md); box-shadow: var(--shadow-card); }
12
+ .select2-search--dropdown .select2-search__field { border-color: var(--color-border-input); border-radius: var(--radius-sm); padding: 6px 10px; }
13
+ .char-counter { font-size: 0.78rem; color: var(--color-text-muted); }
14
+ .char-counter.warn { color: #f59e0b; }
15
+ .char-counter.over { color: #ef4444; }
16
+ .audio-player-wrap { background: var(--color-bg-input); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 1.5rem; }
17
+ audio { width: 100%; border-radius: 8px; }
18
+ </style>
19
+ {% endblock %}
20
+
21
+ {% block content %}
22
+ <div id="loaderOverlay" class="loader-overlay">
23
+ <div class="spinner-pro"></div>
24
+ <p class="mt-3 fw-bold" style="color: var(--color-primary)">Đang tạo giọng nói...</p>
25
+ </div>
26
+
27
+ <div class="card card-custom">
28
+ <!-- Header chung — giữ nguyên khi chuyển tab -->
29
+ <div class="card-header bg-white border-0 pt-4 text-center">
30
+ <h3 class="fw-bold" style="color: var(--color-primary)">TEXT TO SPEECH</h3>
31
+ <p class="text-muted">Chuyển văn bản thành giọng nói tự nhiên</p>
32
+ <ul class="nav nav-pills justify-content-center mb-0" id="pills-tab">
33
+ <li class="nav-item">
34
+ <a class="nav-link" href="/tts/vieneu">
35
+ <i class="fas fa-dragon me-1"></i> VieNeu-TTS
36
+ </a>
37
+ </li>
38
+ <li class="nav-item">
39
+ <a class="nav-link active" href="/tts/gtts">
40
+ <i class="fab fa-google me-1"></i> Google TTS
41
+ </a>
42
+ </li>
43
+ <li class="nav-item">
44
+ <a class="nav-link" href="/tts/edge">
45
+ <i class="fab fa-microsoft me-1"></i> Edge TTS
46
+ </a>
47
+ </li>
48
+ </ul>
49
+ </div>
50
+
51
+ <div class="card-body p-4">
52
+
53
+ <!-- Cài đặt -->
54
+ <div class="row g-3 mb-4">
55
+ <div class="col-md-8">
56
+ <label class="form-label">Ngôn ngữ</label>
57
+ <select id="gttsLang" class="form-select">
58
+ <option value="">Đang tải...</option>
59
+ </select>
60
+ </div>
61
+ <div class="col-md-4 d-flex align-items-end pb-1">
62
+ <div class="form-check">
63
+ <input class="form-check-input" type="checkbox" id="gttsSlow">
64
+ <label class="form-check-label" for="gttsSlow">
65
+ <i class="fas fa-turtle me-1 text-muted"></i> Đọc chậm hơn
66
+ </label>
67
+ </div>
68
+ </div>
69
+ </div>
70
+
71
+ <!-- Văn bản -->
72
+ <div class="mb-4">
73
+ <label class="form-label d-flex justify-content-between">
74
+ <span>Văn bản <small class="text-muted fw-normal">(50+ ngôn ngữ, tối đa 5.000 ký tự)</small></span>
75
+ <span class="char-counter" id="charCounter">0 / 5.000</span>
76
+ </label>
77
+ <textarea id="ttsText" class="form-control" rows="7"
78
+ placeholder="Nhập văn bản bằng bất kỳ ngôn ngữ nào..."></textarea>
79
+ </div>
80
+
81
+ <button class="btn-primary-custom mb-4" id="generateBtn">
82
+ <i class="fas fa-play me-2"></i> Tạo giọng nói
83
+ </button>
84
+
85
+ <div id="errorArea" class="mb-3 d-none">
86
+ <div class="alert alert-danger d-flex align-items-center gap-2 mb-0">
87
+ <i class="fas fa-exclamation-circle"></i>
88
+ <span id="errorMsg"></span>
89
+ </div>
90
+ </div>
91
+
92
+ <div id="audioSection" class="d-none">
93
+ <hr class="my-3">
94
+ <div class="audio-player-wrap">
95
+ <div class="d-flex justify-content-between align-items-center mb-3">
96
+ <div class="fw-semibold"><i class="fas fa-check-circle text-success me-1"></i> Hoàn thành</div>
97
+ <a id="downloadLink" href="#" class="btn btn-success btn-sm px-3">
98
+ <i class="fas fa-download me-1"></i> Tải về MP3
99
+ </a>
100
+ </div>
101
+ <audio id="audioPlayer" controls></audio>
102
+ </div>
103
+ </div>
104
+
105
+ </div>
106
+ </div>
107
+ {% endblock %}
108
+
109
+ {% block scripts %}
110
+ <script src="https://cdn.jsdelivr.net/npm/jquery@3.7.1/dist/jquery.min.js"></script>
111
+ <script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
112
+ <script>
113
+ async function loadLangs() {
114
+ try {
115
+ const res = await fetch('/api/gtts-langs');
116
+ const langs = await res.json();
117
+ const sel = document.getElementById('gttsLang');
118
+ sel.innerHTML = '';
119
+ langs.forEach(l => {
120
+ const opt = document.createElement('option');
121
+ opt.value = l.code; opt.textContent = l.name;
122
+ if (l.code === 'vi') opt.selected = true;
123
+ sel.appendChild(opt);
124
+ });
125
+ $('#gttsLang').select2({ placeholder: 'Chọn ngôn ngữ', width: '100%' });
126
+ } catch(e) { console.error(e); }
127
+ }
128
+ loadLangs();
129
+
130
+ document.getElementById('ttsText').addEventListener('input', function() {
131
+ const len = this.value.length;
132
+ const el = document.getElementById('charCounter');
133
+ el.textContent = `${len.toLocaleString()} / 5.000`;
134
+ el.className = 'char-counter' + (len>5000?' over':len>4200?' warn':'');
135
+ });
136
+
137
+ document.getElementById('generateBtn').addEventListener('click', async () => {
138
+ const text = document.getElementById('ttsText').value.trim();
139
+ if (!text) { showError('Vui lòng nhập văn bản'); return; }
140
+ if (text.length > 5000) { showError(`Văn bản quá dài (${text.length}/5000)`); return; }
141
+
142
+ document.getElementById('loaderOverlay').style.display = 'flex';
143
+ document.getElementById('errorArea').classList.add('d-none');
144
+ document.getElementById('audioSection').classList.add('d-none');
145
+
146
+ const fd = new FormData();
147
+ fd.append('text', text);
148
+ fd.append('engine', 'gtts');
149
+ fd.append('gtts_lang', $('#gttsLang').val());
150
+ fd.append('gtts_slow', document.getElementById('gttsSlow').checked);
151
+
152
+ try {
153
+ const res = await fetch('/tts/generate', {method:'POST', body:fd});
154
+ const data = await res.json();
155
+ if (data.error) { showError(data.error); return; }
156
+ const p = document.getElementById('audioPlayer');
157
+ p.src = data.audio_url + '?t=' + Date.now();
158
+ p.load(); p.play();
159
+ document.getElementById('downloadLink').href = data.audio_url;
160
+ document.getElementById('downloadLink').download = data.filename;
161
+ document.getElementById('audioSection').classList.remove('d-none');
162
+ } catch(e) { showError('Lỗi kết nối. Vui lòng thử lại.'); }
163
+ finally { document.getElementById('loaderOverlay').style.display = 'none'; }
164
+ });
165
+
166
+ function showError(msg) {
167
+ document.getElementById('errorMsg').textContent = msg;
168
+ document.getElementById('errorArea').classList.remove('d-none');
169
+ }
170
+ </script>
171
+ {% endblock %}
templates/tools/tts/vieneu.html ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "layout.html" %}
2
+
3
+ {% block title %}Text to Speech - Pum's Toolkit{% endblock %}
4
+
5
+ {% block styles %}
6
+ <style>
7
+ .voice-card {
8
+ border: 1.5px solid var(--color-border);
9
+ border-radius: var(--radius-md);
10
+ padding: 0.75rem 1rem;
11
+ cursor: pointer;
12
+ transition: all 0.15s;
13
+ display: flex; align-items: center; gap: 10px;
14
+ background: #fff;
15
+ text-decoration: none;
16
+ }
17
+ .voice-card:hover { border-color: var(--color-primary); background: var(--color-primary-hover); }
18
+ .voice-card.active { border-color: var(--color-primary); background: var(--color-primary-light); }
19
+ .voice-card .voice-avatar { width: 38px; height: 38px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1rem; font-weight: 700; flex-shrink: 0; }
20
+ .voice-card .voice-name { font-weight: 600; font-size: 0.875rem; color: var(--color-text-heading); }
21
+ .voice-card .voice-desc { font-size: 0.75rem; color: var(--color-text-muted); }
22
+ .char-counter { font-size: 0.78rem; color: var(--color-text-muted); }
23
+ .char-counter.warn { color: #f59e0b; }
24
+ .char-counter.over { color: #ef4444; }
25
+ .audio-player-wrap { background: var(--color-bg-input); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 1.5rem; }
26
+ audio { width: 100%; border-radius: 8px; }
27
+ .drop-zone { border: 2px dashed var(--color-border-input); border-radius: var(--radius-md); padding: 1.2rem; text-align: center; cursor: pointer; transition: border-color 0.2s, background 0.2s; background: var(--color-bg-input); position: relative; font-size: 0.85rem; }
28
+ .drop-zone:hover { border-color: var(--color-primary); background: var(--color-primary-hover); }
29
+ .drop-zone input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
30
+ .mode-btn { border: 1.5px solid var(--color-border); border-radius: var(--radius-sm); padding: 0.4rem 0.9rem; font-size: 0.82rem; font-weight: 600; color: var(--color-text-muted); background: #fff; cursor: pointer; transition: all 0.15s; }
31
+ .mode-btn.active { background: var(--color-primary-light); color: var(--color-primary); border-color: var(--color-primary); }
32
+ </style>
33
+ {% endblock %}
34
+
35
+ {% block content %}
36
+ <div id="loaderOverlay" class="loader-overlay">
37
+ <div class="spinner-pro"></div>
38
+ <p class="mt-3 fw-bold" style="color: var(--color-primary)">Đang tạo giọng nói...</p>
39
+ </div>
40
+
41
+ <div class="card card-custom">
42
+ <!-- Header chung — giữ nguyên khi chuyển tab -->
43
+ <div class="card-header bg-white border-0 pt-4 text-center">
44
+ <h3 class="fw-bold" style="color: var(--color-primary)">TEXT TO SPEECH</h3>
45
+ <p class="text-muted">Chuyển văn bản thành giọng nói tự nhiên</p>
46
+ <ul class="nav nav-pills justify-content-center mb-0" id="pills-tab">
47
+ <li class="nav-item">
48
+ <a class="nav-link active" href="/tts/vieneu">
49
+ <i class="fas fa-dragon me-1"></i> VieNeu-TTS
50
+ </a>
51
+ </li>
52
+ <li class="nav-item">
53
+ <a class="nav-link" href="/tts/gtts">
54
+ <i class="fab fa-google me-1"></i> Google TTS
55
+ </a>
56
+ </li>
57
+ <li class="nav-item">
58
+ <a class="nav-link" href="/tts/edge">
59
+ <i class="fab fa-microsoft me-1"></i> Edge TTS
60
+ </a>
61
+ </li>
62
+ </ul>
63
+ </div>
64
+
65
+ <div class="card-body p-4">
66
+
67
+ <!-- Văn bản -->
68
+ <div class="mb-3">
69
+ <label class="form-label d-flex justify-content-between">
70
+ <span>Văn bản <small class="text-muted fw-normal">(Hỗ trợ song ngữ Anh–Việt, tag: [cười] [thở dài] [hắng giọng])</small></span>
71
+ <span class="char-counter" id="charCounter">0 / 3.000</span>
72
+ </label>
73
+ <textarea id="ttsText" class="form-control" rows="6"
74
+ placeholder="Nhập văn bản... Ví dụ: Xin chào! This is a bilingual demo [cười]."></textarea>
75
+ <div class="d-flex gap-2 mt-2 flex-wrap">
76
+ <button class="btn btn-sm btn-outline-secondary" onclick="insertTag('[cười]')"><i class="fas fa-laugh me-1"></i>[cười]</button>
77
+ <button class="btn btn-sm btn-outline-secondary" onclick="insertTag('[thở dài]')"><i class="fas fa-wind me-1"></i>[thở dài]</button>
78
+ <button class="btn btn-sm btn-outline-secondary" onclick="insertTag('[hắng giọng]')"><i class="fas fa-comment-medical me-1"></i>[hắng giọng]</button>
79
+ </div>
80
+ </div>
81
+
82
+ <!-- Giọng đọc -->
83
+ <div class="mb-4">
84
+ <label class="form-label d-flex justify-content-between align-items-center">
85
+ <span>Giọng đọc</span>
86
+ <div class="d-flex gap-2">
87
+ <button class="mode-btn active" data-mode="preset" onclick="switchMode('preset')">
88
+ <i class="fas fa-user-circle me-1"></i> Giọng có sẵn
89
+ </button>
90
+ <button class="mode-btn" data-mode="clone" onclick="switchMode('clone')">
91
+ <i class="fas fa-fingerprint me-1"></i> Clone giọng
92
+ </button>
93
+ </div>
94
+ </label>
95
+ <div id="presetVoices">
96
+ <div class="row g-2" id="voiceGrid">
97
+ <div class="col-12 text-muted small">Đang tải danh sách giọng...</div>
98
+ </div>
99
+ </div>
100
+ <div id="cloneVoice" class="d-none">
101
+ <div class="drop-zone" onclick="document.getElementById('refAudioInput').click()">
102
+ <input type="file" id="refAudioInput" accept=".wav,.mp3,.ogg,.flac">
103
+ <i class="fas fa-microphone fa-lg text-muted mb-1 d-block"></i>
104
+ <div class="fw-semibold text-muted">Upload audio mẫu (3–5 giây)</div>
105
+ <div class="text-muted" style="opacity:0.7;font-size:0.75rem">WAV, MP3, OGG — giọng rõ, ít nhiễu</div>
106
+ <div class="text-muted mt-1" style="opacity:0.6;font-size:0.75rem"><i class="fas fa-paste me-1"></i>Hoặc nhấn <kbd>Ctrl+V</kbd> để dán</div>
107
+ <div id="refFileName" class="mt-1 fw-bold d-none" style="color:var(--color-primary);font-size:0.82rem"></div>
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <button class="btn-primary-custom mb-4" id="generateBtn">
113
+ <i class="fas fa-play me-2"></i> Tạo giọng nói
114
+ </button>
115
+
116
+ <div id="errorArea" class="mb-3 d-none">
117
+ <div class="alert alert-danger d-flex align-items-center gap-2 mb-0">
118
+ <i class="fas fa-exclamation-circle"></i>
119
+ <span id="errorMsg"></span>
120
+ </div>
121
+ </div>
122
+
123
+ <div id="audioSection" class="d-none">
124
+ <hr class="my-3">
125
+ <div class="audio-player-wrap">
126
+ <div class="d-flex justify-content-between align-items-center mb-3">
127
+ <div class="fw-semibold"><i class="fas fa-check-circle text-success me-1"></i> Hoàn thành</div>
128
+ <a id="downloadLink" href="#" class="btn btn-success btn-sm px-3">
129
+ <i class="fas fa-download me-1"></i> Tải về WAV
130
+ </a>
131
+ </div>
132
+ <audio id="audioPlayer" controls></audio>
133
+ </div>
134
+ </div>
135
+
136
+ </div>
137
+ </div>
138
+ {% endblock %}
139
+
140
+ {% block scripts %}
141
+ <script>
142
+ let selectedVoice = 'Ngọc Lan';
143
+ let voiceMode = 'preset';
144
+
145
+ const voiceColors = { 'nữ': ['#f472b6','#ec4899','#a855f7','#8b5cf6'], 'nam': ['#60a5fa','#3b82f6','#06b6d4','#0ea5e9'] };
146
+ function getVoiceColor(label) {
147
+ const arr = label.includes('nam') ? voiceColors.nam : voiceColors['nữ'];
148
+ return arr[label.split('').reduce((a,c)=>a+c.charCodeAt(0),0) % arr.length];
149
+ }
150
+
151
+ async function loadVoices() {
152
+ try {
153
+ const res = await fetch('/api/tts-voices');
154
+ const data = await res.json();
155
+ if (data.error) return;
156
+ const grid = document.getElementById('voiceGrid');
157
+ grid.innerHTML = '';
158
+ data.forEach(v => {
159
+ const color = getVoiceColor(v.label);
160
+ const [name, desc=''] = v.label.split(' — ');
161
+ const col = document.createElement('div');
162
+ col.className = 'col-md-4 col-sm-6';
163
+ col.innerHTML = `<div class="voice-card ${v.id===selectedVoice?'active':''}" onclick="selectVoice('${v.id}',this)">
164
+ <div class="voice-avatar" style="background:${color}20;color:${color}">${v.id.charAt(0)}</div>
165
+ <div><div class="voice-name">${name}</div><div class="voice-desc">${desc}</div></div>
166
+ </div>`;
167
+ grid.appendChild(col);
168
+ });
169
+ } catch(e) { console.error(e); }
170
+ }
171
+
172
+ function selectVoice(id, el) {
173
+ selectedVoice = id;
174
+ document.querySelectorAll('.voice-card').forEach(c=>c.classList.remove('active'));
175
+ el.classList.add('active');
176
+ }
177
+
178
+ function switchMode(mode) {
179
+ voiceMode = mode;
180
+ document.querySelectorAll('.mode-btn').forEach(b=>b.classList.remove('active'));
181
+ document.querySelector(`[data-mode="${mode}"]`).classList.add('active');
182
+ document.getElementById('presetVoices').classList.toggle('d-none', mode!=='preset');
183
+ document.getElementById('cloneVoice').classList.toggle('d-none', mode!=='clone');
184
+ }
185
+
186
+ document.getElementById('ttsText').addEventListener('input', function() {
187
+ const len = this.value.length;
188
+ const el = document.getElementById('charCounter');
189
+ el.textContent = `${len.toLocaleString()} / 3.000`;
190
+ el.className = 'char-counter' + (len>3000?' over':len>2500?' warn':'');
191
+ });
192
+
193
+ function insertTag(tag) {
194
+ const ta = document.getElementById('ttsText');
195
+ const s = ta.selectionStart;
196
+ ta.value = ta.value.slice(0,s)+tag+ta.value.slice(s);
197
+ ta.selectionStart = ta.selectionEnd = s+tag.length;
198
+ ta.focus(); ta.dispatchEvent(new Event('input'));
199
+ }
200
+
201
+ document.getElementById('refAudioInput').addEventListener('change', e => {
202
+ const f = e.target.files[0];
203
+ if (f) { document.getElementById('refFileName').textContent=`✓ ${f.name}`; document.getElementById('refFileName').classList.remove('d-none'); }
204
+ });
205
+
206
+ document.getElementById('generateBtn').addEventListener('click', async () => {
207
+ const text = document.getElementById('ttsText').value.trim();
208
+ if (!text) { showError('Vui lòng nhập văn bản'); return; }
209
+ if (text.length > 3000) { showError(`Văn bản quá dài (${text.length}/3000)`); return; }
210
+
211
+ document.getElementById('loaderOverlay').style.display = 'flex';
212
+ document.getElementById('errorArea').classList.add('d-none');
213
+ document.getElementById('audioSection').classList.add('d-none');
214
+
215
+ const fd = new FormData();
216
+ fd.append('text', text);
217
+ fd.append('engine', 'vieneu');
218
+ if (voiceMode === 'clone') {
219
+ const f = document.getElementById('refAudioInput').files[0];
220
+ if (!f) { showError('Vui lòng upload audio mẫu'); document.getElementById('loaderOverlay').style.display='none'; return; }
221
+ fd.append('ref_audio', f);
222
+ } else {
223
+ fd.append('voice', selectedVoice);
224
+ }
225
+
226
+ try {
227
+ const res = await fetch('/tts/generate', {method:'POST', body:fd});
228
+ const data = await res.json();
229
+ if (data.error) { showError(data.error); return; }
230
+ const p = document.getElementById('audioPlayer');
231
+ p.src = data.audio_url + '?t=' + Date.now();
232
+ p.load(); p.play();
233
+ document.getElementById('downloadLink').href = data.audio_url;
234
+ document.getElementById('downloadLink').download = data.filename;
235
+ document.getElementById('audioSection').classList.remove('d-none');
236
+ } catch(e) { showError('Lỗi kết nối. Vui lòng thử lại.'); }
237
+ finally { document.getElementById('loaderOverlay').style.display='none'; }
238
+ });
239
+
240
+ function showError(msg) {
241
+ document.getElementById('errorMsg').textContent = msg;
242
+ document.getElementById('errorArea').classList.remove('d-none');
243
+ }
244
+
245
+ loadVoices();
246
+ </script>
247
+ {% endblock %}