Ptul2x5 commited on
Commit
c7947de
·
verified ·
1 Parent(s): eb13d78

Upload 10 files

Browse files
Files changed (9) hide show
  1. app.py +133 -8
  2. forms.py +46 -0
  3. models.py +43 -0
  4. requirements.txt +6 -0
  5. static/css/style.css +65 -17
  6. static/js/app.js +158 -46
  7. templates/index.html +83 -4
  8. templates/login.html +113 -0
  9. templates/register.html +125 -0
app.py CHANGED
@@ -1,11 +1,31 @@
1
  import os
2
  import torch
3
  from transformers import AutoTokenizer
4
- from flask import Flask, request, jsonify, render_template
 
 
 
5
  from PhoBERTMultiTask import PhoBERTMultiTask
6
 
7
  app = Flask(__name__)
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
10
 
11
  # === Load tokenizer & model ===
@@ -27,14 +47,93 @@ print("✅ Model đã sẵn sàng!")
27
 
28
  # ====== ROUTES ======
29
  @app.route("/", methods=["GET"])
 
30
  def home():
31
  return render_template("index.html")
32
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  @app.route("/api/health", methods=["GET"])
34
  def health():
35
  return jsonify({"status": "healthy", "message": "✅ PhoBERT MultiTask API is running!"})
36
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  @app.route("/predict", methods=["POST"])
 
38
  def predict():
39
  try:
40
  data = request.get_json()
@@ -59,21 +158,47 @@ def predict():
59
  topic = torch.argmax(logits_topic, dim=1).item()
60
 
61
  # Mapping
62
- sent_map = {0: "negative", 1: "neutral", 2: "positive"}
63
- topic_map = {0: "lecturer", 1: "training_program", 2: "facility", 3: "others"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
 
65
  return jsonify({
66
- "sentiment": sent_map[sent],
67
- "topic": topic_map[topic],
68
  "confidence": {
69
- "sentiment": float(torch.softmax(logits_sent, dim=1).max().item()),
70
- "topic": float(torch.softmax(logits_topic, dim=1).max().item())
71
  }
72
  })
73
  except Exception as e:
74
  return jsonify({"error": f"Có lỗi xảy ra khi xử lý: {str(e)}"}), 500
75
 
76
 
 
 
 
 
 
77
  if __name__ == "__main__":
78
  # Hugging Face luôn yêu cầu port = 7860
79
- app.run(host="0.0.0.0", port=7860)
 
1
  import os
2
  import torch
3
  from transformers import AutoTokenizer
4
+ from flask import Flask, request, jsonify, render_template, redirect, url_for, flash
5
+ from flask_login import LoginManager, login_user, logout_user, login_required, current_user
6
+ from models import db, User, Feedback
7
+ from forms import RegistrationForm, LoginForm
8
  from PhoBERTMultiTask import PhoBERTMultiTask
9
 
10
  app = Flask(__name__)
11
 
12
+ # Cấu hình
13
+ app.config['SECRET_KEY'] = 'your-secret-key-change-this-in-production'
14
+ app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///feedback_analysis.db'
15
+ app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
16
+
17
+ # Khởi tạo extensions
18
+ db.init_app(app)
19
+ login_manager = LoginManager()
20
+ login_manager.init_app(app)
21
+ login_manager.login_view = 'login'
22
+ login_manager.login_message = 'Vui lòng đăng nhập để sử dụng hệ thống phân tích feedback.'
23
+ login_manager.login_message_category = 'info'
24
+
25
+ @login_manager.user_loader
26
+ def load_user(user_id):
27
+ return User.query.get(int(user_id))
28
+
29
  device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
30
 
31
  # === Load tokenizer & model ===
 
47
 
48
  # ====== ROUTES ======
49
  @app.route("/", methods=["GET"])
50
+ @login_required
51
  def home():
52
  return render_template("index.html")
53
 
54
+ @app.route("/register", methods=["GET", "POST"])
55
+ def register():
56
+ if current_user.is_authenticated:
57
+ return redirect(url_for('home'))
58
+
59
+ form = RegistrationForm()
60
+ if form.validate_on_submit():
61
+ user = User(username=form.username.data)
62
+ user.set_password(form.password.data)
63
+ db.session.add(user)
64
+ db.session.commit()
65
+ flash('Đăng ký thành công! Vui lòng đăng nhập.', 'success')
66
+ return redirect(url_for('login'))
67
+
68
+ return render_template('register.html', form=form)
69
+
70
+ @app.route("/login", methods=["GET", "POST"])
71
+ def login():
72
+ if current_user.is_authenticated:
73
+ return redirect(url_for('home'))
74
+
75
+ form = LoginForm()
76
+ if form.validate_on_submit():
77
+ user = User.query.filter_by(username=form.username.data).first()
78
+ if user and user.check_password(form.password.data):
79
+ login_user(user, remember=True)
80
+ flash('Đăng nhập thành công!', 'success')
81
+ next_page = request.args.get('next')
82
+ return redirect(next_page) if next_page else redirect(url_for('home'))
83
+ else:
84
+ flash('Tên đăng nhập hoặc mật khẩu không đúng.', 'danger')
85
+
86
+ return render_template('login.html', form=form)
87
+
88
+ @app.route("/logout")
89
+ @login_required
90
+ def logout():
91
+ logout_user()
92
+ flash('Đã đăng xuất thành công!', 'info')
93
+ return redirect(url_for('home'))
94
+
95
  @app.route("/api/health", methods=["GET"])
96
  def health():
97
  return jsonify({"status": "healthy", "message": "✅ PhoBERT MultiTask API is running!"})
98
 
99
+ @app.route("/api/feedback-history", methods=["GET"])
100
+ @login_required
101
+ def get_feedback_history():
102
+ """Lấy lịch sử feedback của user hiện tại"""
103
+ try:
104
+ page = request.args.get('page', 1, type=int)
105
+ per_page = request.args.get('per_page', 10, type=int)
106
+
107
+ # Lấy feedback của user hiện tại, sắp xếp theo thời gian mới nhất
108
+ feedbacks = Feedback.query.filter_by(user_id=current_user.id)\
109
+ .order_by(Feedback.created_at.desc())\
110
+ .paginate(page=page, per_page=per_page, error_out=False)
111
+
112
+ feedback_list = []
113
+ for feedback in feedbacks.items:
114
+ feedback_list.append({
115
+ 'id': feedback.id,
116
+ 'text': feedback.text,
117
+ 'sentiment': feedback.sentiment,
118
+ 'topic': feedback.topic,
119
+ 'sentiment_confidence': feedback.sentiment_confidence,
120
+ 'topic_confidence': feedback.topic_confidence,
121
+ 'created_at': feedback.created_at.isoformat()
122
+ })
123
+
124
+ return jsonify({
125
+ 'feedbacks': feedback_list,
126
+ 'total': feedbacks.total,
127
+ 'pages': feedbacks.pages,
128
+ 'current_page': page,
129
+ 'has_next': feedbacks.has_next,
130
+ 'has_prev': feedbacks.has_prev
131
+ })
132
+ except Exception as e:
133
+ return jsonify({"error": f"Có lỗi xảy ra: {str(e)}"}), 500
134
+
135
  @app.route("/predict", methods=["POST"])
136
+ @login_required
137
  def predict():
138
  try:
139
  data = request.get_json()
 
158
  topic = torch.argmax(logits_topic, dim=1).item()
159
 
160
  # Mapping
161
+ SENTIMENT_MAP = {0: "negative", 1: "neutral", 2: "positive"}
162
+ TOPIC_MAP = {0: "lecturer", 1: "training_program", 2: "facility", 3: "others"}
163
+
164
+ sentiment = SENTIMENT_MAP[sent]
165
+ topic_result = TOPIC_MAP[topic]
166
+ sentiment_confidence = float(torch.softmax(logits_sent, dim=1).max().item())
167
+ topic_confidence = float(torch.softmax(logits_topic, dim=1).max().item())
168
+
169
+ # Lưu feedback vào database
170
+ try:
171
+ feedback = Feedback(
172
+ text=text,
173
+ sentiment=sentiment,
174
+ topic=topic_result,
175
+ sentiment_confidence=sentiment_confidence,
176
+ topic_confidence=topic_confidence,
177
+ user_id=current_user.id
178
+ )
179
+ db.session.add(feedback)
180
+ db.session.commit()
181
+ except Exception as db_error:
182
+ print(f"Database error: {db_error}")
183
+ # Không dừng quá trình nếu lưu DB thất bại
184
 
185
  return jsonify({
186
+ "sentiment": sentiment,
187
+ "topic": topic_result,
188
  "confidence": {
189
+ "sentiment": sentiment_confidence,
190
+ "topic": topic_confidence
191
  }
192
  })
193
  except Exception as e:
194
  return jsonify({"error": f"Có lỗi xảy ra khi xử lý: {str(e)}"}), 500
195
 
196
 
197
+ # Khởi tạo database
198
+ with app.app_context():
199
+ db.create_all()
200
+ print("✅ Database đã sẵn sàng!")
201
+
202
  if __name__ == "__main__":
203
  # Hugging Face luôn yêu cầu port = 7860
204
+ app.run(host="0.0.0.0", port=7860, debug=True)
forms.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask_wtf import FlaskForm
2
+ from wtforms import StringField, PasswordField, SubmitField, TextAreaField
3
+ from wtforms.validators import DataRequired, Length, EqualTo, ValidationError
4
+ from models import User
5
+
6
+ class RegistrationForm(FlaskForm):
7
+ username = StringField('Tên đăng nhập', validators=[
8
+ DataRequired(message='Vui lòng nhập tên đăng nhập'),
9
+ Length(min=3, max=20, message='Tên đăng nhập phải từ 3-20 ký tự')
10
+ ])
11
+
12
+ password = PasswordField('Mật khẩu', validators=[
13
+ DataRequired(message='Vui lòng nhập mật khẩu'),
14
+ Length(min=6, message='Mật khẩu phải có ít nhất 6 ký tự')
15
+ ])
16
+
17
+ confirm_password = PasswordField('Xác nhận mật khẩu', validators=[
18
+ DataRequired(message='Vui lòng xác nhận mật khẩu'),
19
+ EqualTo('password', message='Mật khẩu xác nhận không khớp')
20
+ ])
21
+
22
+ submit = SubmitField('Đăng ký')
23
+
24
+ def validate_username(self, username):
25
+ user = User.query.filter_by(username=username.data).first()
26
+ if user:
27
+ raise ValidationError('Tên đăng nhập đã tồn tại. Vui lòng chọn tên khác.')
28
+
29
+ class LoginForm(FlaskForm):
30
+ username = StringField('Tên đăng nhập', validators=[
31
+ DataRequired(message='Vui lòng nhập tên đăng nhập')
32
+ ])
33
+
34
+ password = PasswordField('Mật khẩu', validators=[
35
+ DataRequired(message='Vui lòng nhập mật khẩu')
36
+ ])
37
+
38
+ submit = SubmitField('Đăng nhập')
39
+
40
+ class FeedbackForm(FlaskForm):
41
+ text = TextAreaField('Feedback của bạn', validators=[
42
+ DataRequired(message='Vui lòng nhập feedback'),
43
+ Length(min=10, max=1000, message='Feedback phải từ 10-1000 ký tự')
44
+ ])
45
+
46
+ submit = SubmitField('Phân Tích Feedback')
models.py ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask_sqlalchemy import SQLAlchemy
2
+ from flask_login import UserMixin
3
+ from datetime import datetime
4
+ import bcrypt
5
+
6
+ db = SQLAlchemy()
7
+
8
+ class User(UserMixin, db.Model):
9
+ __tablename__ = 'users'
10
+
11
+ id = db.Column(db.Integer, primary_key=True)
12
+ username = db.Column(db.String(80), unique=True, nullable=False)
13
+ password_hash = db.Column(db.String(128), nullable=False)
14
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
15
+
16
+ # Relationship với feedbacks
17
+ feedbacks = db.relationship('Feedback', backref='user', lazy=True)
18
+
19
+ def set_password(self, password):
20
+ """Hash và lưu password"""
21
+ self.password_hash = bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
22
+
23
+ def check_password(self, password):
24
+ """Kiểm tra password"""
25
+ return bcrypt.checkpw(password.encode('utf-8'), self.password_hash.encode('utf-8'))
26
+
27
+ def __repr__(self):
28
+ return f'<User {self.username}>'
29
+
30
+ class Feedback(db.Model):
31
+ __tablename__ = 'feedbacks'
32
+
33
+ id = db.Column(db.Integer, primary_key=True)
34
+ text = db.Column(db.Text, nullable=False)
35
+ sentiment = db.Column(db.String(20), nullable=False) # positive, neutral, negative
36
+ topic = db.Column(db.String(50), nullable=False) # lecturer, training_program, facility, others
37
+ sentiment_confidence = db.Column(db.Float, nullable=False)
38
+ topic_confidence = db.Column(db.Float, nullable=False)
39
+ user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) # Bắt buộc vì đã login_required
40
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
41
+
42
+ def __repr__(self):
43
+ return f'<Feedback {self.id}: {self.sentiment} - {self.topic}>'
requirements.txt CHANGED
@@ -8,3 +8,9 @@ gunicorn
8
  numpy
9
  requests
10
  safetensors
 
 
 
 
 
 
 
8
  numpy
9
  requests
10
  safetensors
11
+ Flask-SQLAlchemy==3.1.1
12
+ Flask-Login==0.6.3
13
+ Flask-WTF==1.2.1
14
+ WTForms==3.1.2
15
+ Werkzeug==3.0.3
16
+ bcrypt==4.1.3
static/css/style.css CHANGED
@@ -71,10 +71,9 @@ header {
71
  color: #FFFFFF !important;
72
  border-radius: 16px;
73
  margin: 0 0 1.5rem 0;
74
- padding: 1.5rem 2rem 1.5rem 0;
75
  position: relative;
76
  overflow: hidden;
77
- text-align: center;
78
  }
79
 
80
  /* Force header color consistency */
@@ -151,8 +150,8 @@ header p.lead {
151
  }
152
 
153
  .card:hover {
154
- transform: translateY(-8px) scale(1.02);
155
- box-shadow: 0 20px 60px rgba(168, 200, 236, 0.25), 0 8px 32px rgba(0, 0, 0, 0.08);
156
  }
157
 
158
  .card:hover::before {
@@ -176,16 +175,16 @@ header p.lead {
176
 
177
  /* Forms */
178
  .form-control {
179
- border-radius: 12px;
180
- border: 2px solid var(--gray-300);
181
- padding: 1.25rem 1.5rem;
182
- font-size: 1rem;
183
- transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
184
  background: var(--white);
185
  color: var(--gray-900);
186
  position: relative;
187
- box-shadow: 0 4px 15px rgba(168, 200, 236, 0.08);
188
- min-height: 120px;
189
  width: 100%;
190
  }
191
 
@@ -200,16 +199,12 @@ header p.lead {
200
  margin-bottom: 0.5rem;
201
  }
202
 
203
- #feedbackForm .mb-3 {
204
- margin-bottom: 0.75rem !important;
205
- }
206
-
207
  .form-control:focus {
208
  border-color: var(--primary-color);
209
- box-shadow: 0 0 0 4px rgba(168, 200, 236, 0.15), 0 8px 25px rgba(168, 200, 236, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.9);
210
  background: var(--white);
211
  outline: none;
212
- transform: translateY(-2px);
213
  }
214
 
215
  .form-control:focus + .form-label,
@@ -370,6 +365,53 @@ body .card-header.bg-light {
370
  font-size: 0.9rem;
371
  }
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  /* Components */
374
  .spinner-border {
375
  width: 3rem;
@@ -381,6 +423,12 @@ body .card-header.bg-light {
381
  border: 1px solid var(--gray-200);
382
  padding: 1rem 1.5rem;
383
  background: var(--white);
 
 
 
 
 
 
384
  }
385
 
386
  blockquote {
 
71
  color: #FFFFFF !important;
72
  border-radius: 16px;
73
  margin: 0 0 1.5rem 0;
74
+ padding: 1.5rem 2rem;
75
  position: relative;
76
  overflow: hidden;
 
77
  }
78
 
79
  /* Force header color consistency */
 
150
  }
151
 
152
  .card:hover {
153
+ transform: translateY(-2px) scale(1.01);
154
+ box-shadow: 0 8px 25px rgba(168, 200, 236, 0.15), 0 4px 16px rgba(0, 0, 0, 0.05);
155
  }
156
 
157
  .card:hover::before {
 
175
 
176
  /* Forms */
177
  .form-control {
178
+ border-radius: 8px;
179
+ border: 1px solid var(--gray-300);
180
+ padding: 0.75rem 1rem;
181
+ font-size: 0.9rem;
182
+ transition: all 0.2s ease;
183
  background: var(--white);
184
  color: var(--gray-900);
185
  position: relative;
186
+ box-shadow: 0 2px 8px rgba(168, 200, 236, 0.05);
187
+ min-height: 45px;
188
  width: 100%;
189
  }
190
 
 
199
  margin-bottom: 0.5rem;
200
  }
201
 
 
 
 
 
202
  .form-control:focus {
203
  border-color: var(--primary-color);
204
+ box-shadow: 0 0 0 2px rgba(168, 200, 236, 0.1), 0 2px 8px rgba(168, 200, 236, 0.1);
205
  background: var(--white);
206
  outline: none;
207
+ transform: none;
208
  }
209
 
210
  .form-control:focus + .form-label,
 
365
  font-size: 0.9rem;
366
  }
367
 
368
+ /* Header Buttons */
369
+ header .btn-outline-light {
370
+ border-color: rgba(255, 255, 255, 0.5);
371
+ color: #FFFFFF;
372
+ font-weight: 600;
373
+ transition: all 0.3s ease;
374
+ }
375
+
376
+ header .btn-outline-light:hover {
377
+ background-color: rgba(255, 255, 255, 0.1);
378
+ border-color: #FFFFFF;
379
+ color: #FFFFFF;
380
+ transform: translateY(-2px);
381
+ }
382
+
383
+ header .btn-light {
384
+ background-color: rgba(255, 255, 255, 0.9);
385
+ color: var(--gray-700);
386
+ font-weight: 600;
387
+ transition: all 0.3s ease;
388
+ }
389
+
390
+ header .btn-light:hover {
391
+ background-color: #FFFFFF;
392
+ color: var(--gray-800);
393
+ transform: translateY(-2px);
394
+ }
395
+
396
+ /* Dropdown Menu */
397
+ .dropdown-menu {
398
+ border-radius: 12px;
399
+ border: 1px solid var(--gray-200);
400
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
401
+ margin-top: 0.5rem;
402
+ }
403
+
404
+ .dropdown-item {
405
+ padding: 0.75rem 1rem;
406
+ font-weight: 500;
407
+ transition: all 0.2s ease;
408
+ }
409
+
410
+ .dropdown-item:hover {
411
+ background-color: var(--gray-100);
412
+ color: var(--gray-800);
413
+ }
414
+
415
  /* Components */
416
  .spinner-border {
417
  width: 3rem;
 
423
  border: 1px solid var(--gray-200);
424
  padding: 1rem 1.5rem;
425
  background: var(--white);
426
+ transition: opacity 0.5s ease-out, transform 0.5s ease-out;
427
+ }
428
+
429
+ .alert.fade-out {
430
+ opacity: 0;
431
+ transform: translateY(-10px);
432
  }
433
 
434
  blockquote {
static/js/app.js CHANGED
@@ -71,37 +71,12 @@ document.addEventListener('DOMContentLoaded', function() {
71
  const examples = [
72
  "Giảng viên giảng bài rất hay và dễ hiểu, sinh viên rất thích",
73
  "Thầy cô rất nhiệt tình và hỗ trợ sinh viên học tập tốt",
74
- "Giảng viên có kiến thức sâu rộng và phương pháp dạy hiệu quả",
75
- "Thầy cô rất tận tâm và luôn sẵn sàng giải đáp thắc mắc",
76
- "Phong cách giảng dạy của giảng viên rất thu hút và dễ hiểu",
77
  "Chương trình học rất phù hợp và bổ ích cho sinh viên",
78
- "Nội dung môn học rất thực tế và có tính ứng dụng cao",
79
- "Cấu trúc chương trình học rất logic và dễ theo dõi",
80
- "Môn học này giúp sinh viên phát triển kỹ năng tốt",
81
- "Chương trình đào tạo rất toàn diện và chất lượng",
82
  "Cơ sở vật chất rất hiện đại và đầy đủ tiện nghi",
83
- "Phòng học rộng rãi, thoáng mát và có đầy đủ thiết bị",
84
- "Thư viện rất đẹp và có nhiều tài liệu hữu ích",
85
- "Khuôn viên trường rất đẹp và môi trường học tập tốt",
86
- "Phòng thực hành được trang bị đầy đủ và hiện đại",
87
  "Giảng viên giảng bài quá nhanh và khó hiểu",
88
- "Thầy cô không nhiệt tình và ít quan tâm đến sinh viên",
89
- "Phương pháp dạy của giảng viên rất nhàm chán",
90
- "Giảng viên không sẵn sàng giải đáp thắc mắc của sinh viên",
91
- "Thầy cô có thái độ không tốt với sinh viên",
92
  "Chương trình học quá khó và không phù hợp với sinh viên",
93
- "Nội dung môn học quá lý thuyết và thiếu thực hành",
94
- "Cấu trúc chương trình rối rắm và khó theo dõi",
95
- "Môn học này không có tính ứng dụng thực tế",
96
- "Chương trình đào tạo quá nặng và áp lực",
97
  "Phòng học quá nóng và không có điều hòa, rất khó chịu",
98
- " sở vật chất kỹ thiếu thiết bị cần thiết",
99
- "Thư viện quá nhỏ và thiếu chỗ ngồi",
100
- "Khuôn viên trường không được bảo trì tốt",
101
- "Phòng thực hành thiếu thiết bị và không đảm bảo an toàn",
102
- "Môn học này có nội dung bình thường, không có gì đặc biệt",
103
- "Giảng viên dạy ổn, không có gì nổi bật",
104
- "Chương trình học có thể chấp nhận được"
105
  ];
106
 
107
  // Utility Functions
@@ -132,12 +107,7 @@ document.addEventListener('DOMContentLoaded', function() {
132
  elements.textarea.value = '';
133
  elements.results.style.display = 'none';
134
  elements.errorMessage.style.display = 'none';
135
-
136
- const charCount = document.getElementById('charCount');
137
- const charCounter = document.querySelector('.form-text');
138
-
139
- if (charCount) charCount.textContent = '0';
140
- if (charCounter) charCounter.style.color = '#6c757d';
141
  },
142
 
143
  updateResult(type, value) {
@@ -209,6 +179,9 @@ document.addEventListener('DOMContentLoaded', function() {
209
  elements.results.style.display = 'block';
210
  elements.results.classList.add('fade-in');
211
  utils.scrollToResults();
 
 
 
212
  } else {
213
  utils.showError(data.error || 'Có lỗi xảy ra khi phân tích feedback!');
214
  }
@@ -239,23 +212,24 @@ document.addEventListener('DOMContentLoaded', function() {
239
  }
240
  });
241
 
242
- // Dynamic Button Creation
243
- function createButton(text, icon, className, onClick) {
244
- const btn = document.createElement('button');
245
- btn.type = 'button';
246
- btn.className = className;
247
- btn.innerHTML = `<i class="fas ${icon} me-2"></i>${text}`;
248
- btn.onclick = onClick;
249
- return btn;
250
- }
251
-
252
- // Add buttons container
253
  const buttonContainer = document.createElement('div');
254
  buttonContainer.className = 'd-flex justify-content-center gap-2 mt-3';
255
 
256
- buttonContainer.appendChild(createButton('Xóa Form', 'fa-trash', 'btn btn-outline-secondary', utils.clearForm));
257
- buttonContainer.appendChild(createButton('Ví Dụ', 'fa-lightbulb', 'btn btn-outline-info', showExamples));
 
 
 
 
 
 
 
 
 
258
 
 
 
259
  elements.form.appendChild(buttonContainer);
260
 
261
  // Add character counter
@@ -263,4 +237,142 @@ document.addEventListener('DOMContentLoaded', function() {
263
  charCounter.className = 'form-text text-muted';
264
  charCounter.innerHTML = '<i class="fas fa-info-circle me-1"></i>Độ dài: <span id="charCount">0</span> ký tự';
265
  elements.textarea.parentNode.appendChild(charCounter);
266
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  const examples = [
72
  "Giảng viên giảng bài rất hay và dễ hiểu, sinh viên rất thích",
73
  "Thầy cô rất nhiệt tình và hỗ trợ sinh viên học tập tốt",
 
 
 
74
  "Chương trình học rất phù hợp và bổ ích cho sinh viên",
 
 
 
 
75
  "Cơ sở vật chất rất hiện đại và đầy đủ tiện nghi",
 
 
 
 
76
  "Giảng viên giảng bài quá nhanh và khó hiểu",
 
 
 
 
77
  "Chương trình học quá khó và không phù hợp với sinh viên",
 
 
 
 
78
  "Phòng học quá nóng và không có điều hòa, rất khó chịu",
79
+ "Môn học này nội dung bình thường, không đặc biệt"
 
 
 
 
 
 
80
  ];
81
 
82
  // Utility Functions
 
107
  elements.textarea.value = '';
108
  elements.results.style.display = 'none';
109
  elements.errorMessage.style.display = 'none';
110
+ this.updateCharCounter();
 
 
 
 
 
111
  },
112
 
113
  updateResult(type, value) {
 
179
  elements.results.style.display = 'block';
180
  elements.results.classList.add('fade-in');
181
  utils.scrollToResults();
182
+
183
+ // Reload feedback history after successful analysis
184
+ loadFeedbackHistory(1);
185
  } else {
186
  utils.showError(data.error || 'Có lỗi xảy ra khi phân tích feedback!');
187
  }
 
212
  }
213
  });
214
 
215
+ // Add utility buttons
 
 
 
 
 
 
 
 
 
 
216
  const buttonContainer = document.createElement('div');
217
  buttonContainer.className = 'd-flex justify-content-center gap-2 mt-3';
218
 
219
+ const clearBtn = document.createElement('button');
220
+ clearBtn.type = 'button';
221
+ clearBtn.className = 'btn btn-outline-secondary';
222
+ clearBtn.innerHTML = '<i class="fas fa-trash me-2"></i>Xóa Form';
223
+ clearBtn.onclick = () => utils.clearForm();
224
+
225
+ const exampleBtn = document.createElement('button');
226
+ exampleBtn.type = 'button';
227
+ exampleBtn.className = 'btn btn-outline-info';
228
+ exampleBtn.innerHTML = '<i class="fas fa-lightbulb me-2"></i>Ví Dụ';
229
+ exampleBtn.onclick = showExamples;
230
 
231
+ buttonContainer.appendChild(clearBtn);
232
+ buttonContainer.appendChild(exampleBtn);
233
  elements.form.appendChild(buttonContainer);
234
 
235
  // Add character counter
 
237
  charCounter.className = 'form-text text-muted';
238
  charCounter.innerHTML = '<i class="fas fa-info-circle me-1"></i>Độ dài: <span id="charCount">0</span> ký tự';
239
  elements.textarea.parentNode.appendChild(charCounter);
240
+
241
+ // Load feedback history
242
+ loadFeedbackHistory();
243
+ });
244
+
245
+ // Feedback History Functions
246
+ let currentPage = 1;
247
+ const itemsPerPage = 5;
248
+
249
+ async function loadFeedbackHistory(page = 1) {
250
+ const historyLoading = document.getElementById('historyLoading');
251
+ const historyContent = document.getElementById('historyContent');
252
+ const historyPagination = document.getElementById('historyPagination');
253
+
254
+ historyLoading.style.display = 'block';
255
+ historyContent.innerHTML = '';
256
+ historyPagination.innerHTML = '';
257
+
258
+ try {
259
+ const response = await fetch(`/api/feedback-history?page=${page}&per_page=${itemsPerPage}`);
260
+ const data = await response.json();
261
+
262
+ if (response.ok) {
263
+ displayFeedbackHistory(data.feedbacks);
264
+ displayPagination(data, page);
265
+ } else {
266
+ historyContent.innerHTML = '<p class="text-muted text-center">Không thể tải lịch sử feedback.</p>';
267
+ }
268
+ } catch (error) {
269
+ console.error('Error loading feedback history:', error);
270
+ historyContent.innerHTML = '<p class="text-muted text-center">Có lỗi xảy ra khi tải lịch sử.</p>';
271
+ } finally {
272
+ historyLoading.style.display = 'none';
273
+ }
274
+ }
275
+
276
+ function displayFeedbackHistory(feedbacks) {
277
+ const historyContent = document.getElementById('historyContent');
278
+
279
+ if (feedbacks.length === 0) {
280
+ historyContent.innerHTML = '<p class="text-muted text-center">Chưa có feedback nào. Hãy phân tích feedback đầu tiên!</p>';
281
+ return;
282
+ }
283
+
284
+ let html = '';
285
+ feedbacks.forEach(feedback => {
286
+ const sentimentConfig = getSentimentConfig(feedback.sentiment);
287
+ const topicConfig = getTopicConfig(feedback.topic);
288
+ const date = new Date(feedback.created_at).toLocaleString('vi-VN');
289
+
290
+ html += `
291
+ <div class="card mb-3 border-start border-4 border-${getSentimentColor(feedback.sentiment)}">
292
+ <div class="card-body">
293
+ <div class="row">
294
+ <div class="col-md-8">
295
+ <p class="card-text">${feedback.text}</p>
296
+ <small class="text-muted">
297
+ <i class="fas fa-clock me-1"></i>${date}
298
+ </small>
299
+ </div>
300
+ <div class="col-md-4 text-end">
301
+ <div class="mb-2">
302
+ <span class="badge bg-${getSentimentColor(feedback.sentiment)} me-2">
303
+ <i class="fas ${sentimentConfig.icon} me-1"></i>
304
+ ${sentimentConfig.label}
305
+ </span>
306
+ <span class="badge bg-secondary">
307
+ <i class="fas ${topicConfig.icon} me-1"></i>
308
+ ${topicConfig.label}
309
+ </span>
310
+ </div>
311
+ <div class="small text-muted">
312
+ <div>Tin cậy: ${(feedback.sentiment_confidence * 100).toFixed(1)}%</div>
313
+ <div>Chủ đề: ${(feedback.topic_confidence * 100).toFixed(1)}%</div>
314
+ </div>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ </div>
319
+ `;
320
+ });
321
+
322
+ historyContent.innerHTML = html;
323
+ }
324
+
325
+ function displayPagination(data, currentPage) {
326
+ const historyPagination = document.getElementById('historyPagination');
327
+
328
+ if (data.pages <= 1) return;
329
+
330
+ let html = '<nav><ul class="pagination pagination-sm">';
331
+
332
+ // Previous button
333
+ if (data.has_prev) {
334
+ html += `<li class="page-item"><a class="page-link" href="#" onclick="loadFeedbackHistory(${currentPage - 1})">Trước</a></li>`;
335
+ }
336
+
337
+ // Page numbers
338
+ for (let i = 1; i <= data.pages; i++) {
339
+ const active = i === currentPage ? 'active' : '';
340
+ html += `<li class="page-item ${active}"><a class="page-link" href="#" onclick="loadFeedbackHistory(${i})">${i}</a></li>`;
341
+ }
342
+
343
+ // Next button
344
+ if (data.has_next) {
345
+ html += `<li class="page-item"><a class="page-link" href="#" onclick="loadFeedbackHistory(${currentPage + 1})">Sau</a></li>`;
346
+ }
347
+
348
+ html += '</ul></nav>';
349
+ historyPagination.innerHTML = html;
350
+ }
351
+
352
+ function getSentimentConfig(sentiment) {
353
+ const configs = {
354
+ positive: { icon: 'fa-smile', label: 'Tích Cực' },
355
+ neutral: { icon: 'fa-meh', label: 'Trung Tính' },
356
+ negative: { icon: 'fa-frown', label: 'Tiêu Cực' }
357
+ };
358
+ return configs[sentiment] || configs.neutral;
359
+ }
360
+
361
+ function getTopicConfig(topic) {
362
+ const configs = {
363
+ lecturer: { icon: 'fa-user-tie', label: 'Giảng Viên' },
364
+ training_program: { icon: 'fa-graduation-cap', label: 'Chương Trình' },
365
+ facility: { icon: 'fa-building', label: 'Cơ Sở Vật Chất' },
366
+ others: { icon: 'fa-ellipsis-h', label: 'Khác' }
367
+ };
368
+ return configs[topic] || configs.others;
369
+ }
370
+
371
+ function getSentimentColor(sentiment) {
372
+ const colors = {
373
+ positive: 'success',
374
+ neutral: 'warning',
375
+ negative: 'danger'
376
+ };
377
+ return colors[sentiment] || 'secondary';
378
+ }
templates/index.html CHANGED
@@ -12,15 +12,54 @@
12
  <div class="container-fluid">
13
  <!-- Header -->
14
  <header>
15
- <h1 class="display-4 fw-bold text-primary">
16
- <i class="fas fa-graduation-cap me-3"></i>
17
- Student Feedback Analysis
18
- </h1>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </header>
20
 
21
  <!-- Main Content -->
22
  <div class="row justify-content-center">
23
  <div class="col-lg-8">
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  <!-- Input Form -->
25
  <div class="card shadow-lg">
26
  <div class="card-header bg-primary text-white">
@@ -135,6 +174,30 @@
135
  <i class="fas fa-exclamation-triangle me-2"></i>
136
  <span id="errorText"></span>
137
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  </div>
139
  </div>
140
 
@@ -150,5 +213,21 @@
150
  <!-- Scripts -->
151
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
152
  <script src="{{ url_for('static', filename='js/app.js') }}"></script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
153
  </body>
154
  </html>
 
12
  <div class="container-fluid">
13
  <!-- Header -->
14
  <header>
15
+ <div class="d-flex justify-content-between align-items-center">
16
+ <h1 class="display-4 fw-bold text-primary mb-0">
17
+ <i class="fas fa-graduation-cap me-1" style="margin-left: 10px !important;"></i>
18
+ Student Feedback Analysis
19
+ </h1>
20
+ <div class="d-flex align-items-center justify-content-end" style="margin-right: 20px; gap: 16px;">
21
+ <span class="text-white fw-semibold d-flex align-items-center">
22
+ <i class="fas fa-user me-2"></i>
23
+ {{ current_user.username }}
24
+ </span>
25
+ <a href="{{ url_for('logout') }}"
26
+ style="background-color: rgba(255,255,255,0.2);
27
+ border: 1px solid rgba(255,255,255,0.3);
28
+ color: white;
29
+ padding: 8px 8px;
30
+ border-radius: 6px;
31
+ text-decoration: none;
32
+ font-weight: 600;
33
+ display: inline-flex;
34
+ align-items: center;
35
+ justify-content: center;
36
+ font-size: 13px;
37
+ transition: all 0.3s ease;
38
+ backdrop-filter: blur(10px);
39
+ gap: 6px;">
40
+ <i class="fas fa-sign-out-alt"></i>
41
+ <span>Đăng xuất</span>
42
+ </a>
43
+ </div>
44
+ </div>
45
  </header>
46
 
47
  <!-- Main Content -->
48
  <div class="row justify-content-center">
49
  <div class="col-lg-8">
50
+ <!-- Flash Messages -->
51
+ {% with messages = get_flashed_messages(with_categories=true) %}
52
+ {% if messages %}
53
+ {% for category, message in messages %}
54
+ <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
55
+ <i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'info-circle' }} me-2"></i>
56
+ {{ message }}
57
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
58
+ </div>
59
+ {% endfor %}
60
+ {% endif %}
61
+ {% endwith %}
62
+
63
  <!-- Input Form -->
64
  <div class="card shadow-lg">
65
  <div class="card-header bg-primary text-white">
 
174
  <i class="fas fa-exclamation-triangle me-2"></i>
175
  <span id="errorText"></span>
176
  </div>
177
+
178
+ <!-- Feedback History -->
179
+ <div class="card shadow-sm mt-4">
180
+ <div class="card-header bg-primary text-white">
181
+ <h4 class="card-title mb-0">
182
+ <i class="fas fa-history me-2"></i>
183
+ Lịch sử Feedback
184
+ </h4>
185
+ </div>
186
+ <div class="card-body">
187
+ <div id="historyLoading" class="text-center py-3" style="display: none;">
188
+ <div class="spinner-border text-primary" role="status">
189
+ <span class="visually-hidden">Loading...</span>
190
+ </div>
191
+ <p class="mt-2 text-muted">Đang tải lịch sử...</p>
192
+ </div>
193
+ <div id="historyContent">
194
+ <!-- History items will be loaded here -->
195
+ </div>
196
+ <div id="historyPagination" class="d-flex justify-content-center mt-3">
197
+ <!-- Pagination will be loaded here -->
198
+ </div>
199
+ </div>
200
+ </div>
201
  </div>
202
  </div>
203
 
 
213
  <!-- Scripts -->
214
  <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
215
  <script src="{{ url_for('static', filename='js/app.js') }}"></script>
216
+ <script>
217
+ // Auto-hide alerts after 3 seconds with smooth fade
218
+ document.addEventListener('DOMContentLoaded', function() {
219
+ const alerts = document.querySelectorAll('.alert');
220
+ alerts.forEach(function(alert) {
221
+ setTimeout(function() {
222
+ // Add fade-out class for smooth transition
223
+ alert.classList.add('fade-out');
224
+ // Remove from DOM after transition completes
225
+ setTimeout(function() {
226
+ alert.remove();
227
+ }, 500);
228
+ }, 3000);
229
+ });
230
+ });
231
+ </script>
232
  </body>
233
  </html>
templates/login.html ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Đăng nhập - Student Feedback Analysis</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
9
+ <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
10
+ </head>
11
+ <body>
12
+ <div class="container-fluid">
13
+ <!-- Main Content -->
14
+ <div class="row justify-content-center" style="padding-top: 60px;">
15
+ <div class="col-lg-6 col-md-8">
16
+ <!-- Login Form -->
17
+ <div class="card shadow-sm">
18
+ <div class="card-header bg-primary text-white">
19
+ <h3 class="card-title mb-0">
20
+ <i class="fas fa-sign-in-alt me-2"></i>
21
+ Đăng nhập tài khoản
22
+ </h3>
23
+ </div>
24
+ <div class="card-body">
25
+ <!-- Flash Messages -->
26
+ {% with messages = get_flashed_messages(with_categories=true) %}
27
+ {% if messages %}
28
+ {% for category, message in messages %}
29
+ <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
30
+ <i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'info-circle' }} me-2"></i>
31
+ {{ message }}
32
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
33
+ </div>
34
+ {% endfor %}
35
+ {% endif %}
36
+ {% endwith %}
37
+
38
+ <form method="POST">
39
+ {{ form.hidden_tag() }}
40
+
41
+ <div class="mb-3">
42
+ {{ form.username.label(class="form-label fw-bold") }}
43
+ {{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
44
+ {% if form.username.errors %}
45
+ <div class="invalid-feedback">
46
+ {% for error in form.username.errors %}
47
+ {{ error }}
48
+ {% endfor %}
49
+ </div>
50
+ {% endif %}
51
+ </div>
52
+
53
+ <div class="mb-4">
54
+ {{ form.password.label(class="form-label fw-bold") }}
55
+ {{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
56
+ {% if form.password.errors %}
57
+ <div class="invalid-feedback">
58
+ {% for error in form.password.errors %}
59
+ {{ error }}
60
+ {% endfor %}
61
+ </div>
62
+ {% endif %}
63
+ </div>
64
+
65
+ <div class="d-grid">
66
+ {{ form.submit(class="btn btn-primary btn-lg") }}
67
+ </div>
68
+ </form>
69
+
70
+ <hr class="my-4">
71
+
72
+ <div class="text-center">
73
+ <p class="text-muted mb-0">
74
+ Chưa có tài khoản?
75
+ <a href="{{ url_for('register') }}" class="text-decoration-none fw-bold text-secondary">
76
+ Đăng ký ngay
77
+ </a>
78
+ </p>
79
+ </div>
80
+ </div>
81
+ </div>
82
+
83
+ <!-- Back to Home -->
84
+ <div class="text-center mt-3">
85
+ <a href="{{ url_for('home') }}" class="btn btn-outline-secondary">
86
+ <i class="fas fa-arrow-left me-2"></i>
87
+ Về trang chủ
88
+ </a>
89
+ </div>
90
+ </div>
91
+ </div>
92
+ </div>
93
+
94
+ <!-- Scripts -->
95
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
96
+ <script>
97
+ // Auto-hide alerts after 3 seconds with smooth fade
98
+ document.addEventListener('DOMContentLoaded', function() {
99
+ const alerts = document.querySelectorAll('.alert');
100
+ alerts.forEach(function(alert) {
101
+ setTimeout(function() {
102
+ // Add fade-out class for smooth transition
103
+ alert.classList.add('fade-out');
104
+ // Remove from DOM after transition completes
105
+ setTimeout(function() {
106
+ alert.remove();
107
+ }, 500);
108
+ }, 3000);
109
+ });
110
+ });
111
+ </script>
112
+ </body>
113
+ </html>
templates/register.html ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>Đăng ký - Student Feedback Analysis</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
9
+ <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
10
+ </head>
11
+ <body>
12
+ <div class="container-fluid">
13
+ <!-- Main Content -->
14
+ <div class="row justify-content-center" style="padding-top: 60px;">
15
+ <div class="col-lg-6 col-md-8">
16
+ <!-- Registration Form -->
17
+ <div class="card shadow-sm">
18
+ <div class="card-header bg-primary text-white">
19
+ <h3 class="card-title mb-0">
20
+ <i class="fas fa-user-plus me-2"></i>
21
+ Tạo tài khoản mới
22
+ </h3>
23
+ </div>
24
+ <div class="card-body">
25
+ <!-- Flash Messages -->
26
+ {% with messages = get_flashed_messages(with_categories=true) %}
27
+ {% if messages %}
28
+ {% for category, message in messages %}
29
+ <div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
30
+ <i class="fas fa-{{ 'exclamation-triangle' if category == 'error' else 'info-circle' }} me-2"></i>
31
+ {{ message }}
32
+ <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
33
+ </div>
34
+ {% endfor %}
35
+ {% endif %}
36
+ {% endwith %}
37
+
38
+ <form method="POST">
39
+ {{ form.hidden_tag() }}
40
+
41
+ <div class="mb-3">
42
+ {{ form.username.label(class="form-label fw-bold") }}
43
+ {{ form.username(class="form-control" + (" is-invalid" if form.username.errors else "")) }}
44
+ {% if form.username.errors %}
45
+ <div class="invalid-feedback">
46
+ {% for error in form.username.errors %}
47
+ {{ error }}
48
+ {% endfor %}
49
+ </div>
50
+ {% endif %}
51
+ </div>
52
+
53
+ <div class="mb-3">
54
+ {{ form.password.label(class="form-label fw-bold") }}
55
+ {{ form.password(class="form-control" + (" is-invalid" if form.password.errors else "")) }}
56
+ {% if form.password.errors %}
57
+ <div class="invalid-feedback">
58
+ {% for error in form.password.errors %}
59
+ {{ error }}
60
+ {% endfor %}
61
+ </div>
62
+ {% endif %}
63
+ </div>
64
+
65
+ <div class="mb-4">
66
+ {{ form.confirm_password.label(class="form-label fw-bold") }}
67
+ {{ form.confirm_password(class="form-control" + (" is-invalid" if form.confirm_password.errors else "")) }}
68
+ {% if form.confirm_password.errors %}
69
+ <div class="invalid-feedback">
70
+ {% for error in form.confirm_password.errors %}
71
+ {{ error }}
72
+ {% endfor %}
73
+ </div>
74
+ {% endif %}
75
+ </div>
76
+
77
+ <div class="d-grid">
78
+ {{ form.submit(class="btn btn-primary btn-lg") }}
79
+ </div>
80
+ </form>
81
+
82
+ <hr class="my-4">
83
+
84
+ <div class="text-center">
85
+ <p class="text-muted mb-0">
86
+ Đã có tài khoản?
87
+ <a href="{{ url_for('login') }}" class="text-decoration-none fw-bold text-secondary">
88
+ Đăng nhập ngay
89
+ </a>
90
+ </p>
91
+ </div>
92
+ </div>
93
+ </div>
94
+
95
+ <!-- Back to Home -->
96
+ <div class="text-center mt-3">
97
+ <a href="{{ url_for('home') }}" class="btn btn-outline-secondary">
98
+ <i class="fas fa-arrow-left me-2"></i>
99
+ Về trang chủ
100
+ </a>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+ <!-- Scripts -->
107
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
108
+ <script>
109
+ // Auto-hide alerts after 3 seconds with smooth fade
110
+ document.addEventListener('DOMContentLoaded', function() {
111
+ const alerts = document.querySelectorAll('.alert');
112
+ alerts.forEach(function(alert) {
113
+ setTimeout(function() {
114
+ // Add fade-out class for smooth transition
115
+ alert.classList.add('fade-out');
116
+ // Remove from DOM after transition completes
117
+ setTimeout(function() {
118
+ alert.remove();
119
+ }, 500);
120
+ }, 3000);
121
+ });
122
+ });
123
+ </script>
124
+ </body>
125
+ </html>