Spaces:
Sleeping
Sleeping
Commit ·
440b256
0
Parent(s):
Initial commit: PUM'S TOOLKIT - AI-powered tools collection
Browse files- .gitignore +6 -0
- Dockerfile +19 -0
- README.md +19 -0
- app.py +649 -0
- requirements.txt +10 -0
- static/logo.png +0 -0
- static/logo2.png +0 -0
- static/theme.css +341 -0
- templates/index.html +74 -0
- templates/layout.html +174 -0
- templates/tools/image_studio/index.html +292 -0
- templates/tools/smart_pdf/index.html +351 -0
- templates/tools/translator/image.html +315 -0
- templates/tools/translator/index.html +373 -0
- templates/tools/tts/edge.html +263 -0
- templates/tools/tts/gtts.html +171 -0
- templates/tools/tts/vieneu.html +247 -0
.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 %}
|