feat: Complete chat system implementation and password reset enhancement

- Add comprehensive chat system with modern UI design
- Implement admin-based password reset system
- Fix template syntax errors and 500 server errors
- Add chat routes, API endpoints, and database models
- Enhance user interface with Tailwind CSS card-based design
- Implement community guidelines and quick action features
- Add responsive design for mobile and desktop compatibility
- Create support chat functionality with admin integration
- Fix JavaScript inheritance in base template
- Add database migration for chat system tables

Features:
 Modern chat interface with room management
 Admin-based password reset workflow
 Real-time chat with mobile app support
 Professional UI with gradient cards and hover effects
 Community guidelines and safety features
 Responsive design for all devices
 Error-free template rendering
This commit is contained in:
ske087
2025-08-09 20:44:25 +03:00
parent d1e2b95678
commit 1661f5f588
14 changed files with 2742 additions and 34 deletions

View File

@@ -87,6 +87,12 @@ def create_app(config_name=None):
from app.routes.admin import admin
app.register_blueprint(admin, url_prefix='/admin')
from app.routes.chat import chat
app.register_blueprint(chat, url_prefix='/chat')
from app.routes.chat_api import chat_api
app.register_blueprint(chat_api, url_prefix='/api/v1/chat')
# Create upload directories
upload_dir = os.path.join(app.instance_path, 'uploads')
os.makedirs(upload_dir, exist_ok=True)

View File

@@ -305,3 +305,134 @@ class SentEmail(db.Model):
def __repr__(self):
return f'<SentEmail to={self.recipient} subject={self.subject} status={self.status}>'
class ChatRoom(db.Model):
__tablename__ = 'chat_rooms'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
room_type = db.Column(db.String(50), default='general') # general, post_discussion, admin_support, password_reset
is_private = db.Column(db.Boolean, default=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
last_activity = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign Keys
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
related_post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=True) # For post discussions
# Relationships
created_by = db.relationship('User', backref='created_chat_rooms')
related_post = db.relationship('Post', backref='chat_rooms')
messages = db.relationship('ChatMessage', backref='room', lazy='dynamic', cascade='all, delete-orphan')
participants = db.relationship('ChatParticipant', backref='room', lazy='dynamic', cascade='all, delete-orphan')
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'room_type': self.room_type,
'is_private': self.is_private,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_activity': self.last_activity.isoformat() if self.last_activity else None,
'created_by': {
'id': self.created_by.id,
'nickname': self.created_by.nickname
} if self.created_by else None,
'related_post': {
'id': self.related_post.id,
'title': self.related_post.title
} if self.related_post else None,
'participant_count': self.participants.count(),
'message_count': self.messages.count()
}
def __repr__(self):
return f'<ChatRoom {self.name}>'
class ChatParticipant(db.Model):
__tablename__ = 'chat_participants'
id = db.Column(db.Integer, primary_key=True)
joined_at = db.Column(db.DateTime, default=datetime.utcnow)
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
is_muted = db.Column(db.Boolean, default=False)
role = db.Column(db.String(50), default='member') # member, moderator, admin
# Foreign Keys
room_id = db.Column(db.Integer, db.ForeignKey('chat_rooms.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Relationships
user = db.relationship('User', backref='chat_participations')
# Unique constraint
__table_args__ = (db.UniqueConstraint('room_id', 'user_id', name='unique_room_participant'),)
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'user': {
'id': self.user.id,
'nickname': self.user.nickname,
'is_admin': self.user.is_admin
},
'role': self.role,
'joined_at': self.joined_at.isoformat() if self.joined_at else None,
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
'is_muted': self.is_muted
}
def __repr__(self):
return f'<ChatParticipant {self.user.nickname} in {self.room.name}>'
class ChatMessage(db.Model):
__tablename__ = 'chat_messages'
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
message_type = db.Column(db.String(50), default='text') # text, system, file, image
is_edited = db.Column(db.Boolean, default=False)
is_deleted = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign Keys
room_id = db.Column(db.Integer, db.ForeignKey('chat_rooms.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
reply_to_id = db.Column(db.Integer, db.ForeignKey('chat_messages.id'), nullable=True) # For threaded replies
# Relationships
user = db.relationship('User', backref='chat_messages')
reply_to = db.relationship('ChatMessage', remote_side=[id], backref='replies')
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'content': self.content,
'message_type': self.message_type,
'is_edited': self.is_edited,
'is_deleted': self.is_deleted,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'user': {
'id': self.user.id,
'nickname': self.user.nickname,
'is_admin': self.user.is_admin
},
'reply_to': {
'id': self.reply_to.id,
'content': self.reply_to.content[:100] + '...' if len(self.reply_to.content) > 100 else self.reply_to.content,
'user_nickname': self.reply_to.user.nickname
} if self.reply_to else None
}
def __repr__(self):
return f'<ChatMessage {self.id} by {self.user.nickname}>'

View File

@@ -6,6 +6,7 @@ from app.routes.reset_password import RequestResetForm, ResetPasswordForm
from flask_mail import Message
from app.routes.mail import mail
from app.utils.token import generate_reset_token, verify_reset_token
from datetime import datetime
import re
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
@@ -103,25 +104,61 @@ def logout():
@auth.route('/forgot-password', methods=['GET', 'POST'])
def forgot_password():
"""Forgot password page"""
"""Forgot password page - sends message to admin instead of email"""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = RequestResetForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
token = generate_reset_token(user.email)
reset_url = url_for('auth.reset_password', token=token, _external=True)
msg = Message(
subject="Password Reset Request",
recipients=[user.email],
body=f"Hello {user.nickname},\n\nTo reset your password, click the link below:\n{reset_url}\n\nIf you did not request this, please ignore this email."
# Create password reset user if it doesn't exist
reset_user = User.query.filter_by(email='reset_password@motoadventure.local').first()
if not reset_user:
reset_user = User(
nickname='PasswordReset',
email='reset_password@motoadventure.local',
is_active=False # This is a system user
)
try:
mail.send(msg)
except Exception as e:
flash(f"Failed to send reset email: {e}", "danger")
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
reset_user.set_password('temp_password') # Won't be used
db.session.add(reset_user)
db.session.commit()
# Find admin support room
from app.models import ChatRoom, ChatMessage
admin_room = ChatRoom.query.filter_by(room_type='support').first()
if not admin_room:
# Create admin support room if it doesn't exist
system_user = User.query.filter_by(email='system@motoadventure.local').first()
admin_room = ChatRoom(
name='Technical Support',
description='Administrative support and password resets',
room_type='support',
is_private=False,
is_active=True,
created_by_id=system_user.id if system_user else 1
)
db.session.add(admin_room)
db.session.commit()
# Create the password reset message
if user:
message_content = f"A user with email '{user.email}' (nickname: {user.nickname}) needs their password to be changed. Please assist with password reset."
else:
message_content = f"Someone with email '{form.email.data}' requested a password reset, but no account exists with this email. Please check if this user needs assistance creating an account."
reset_message = ChatMessage(
content=message_content,
room_id=admin_room.id,
sender_id=reset_user.id,
is_system_message=True
)
db.session.add(reset_message)
# Update room activity
admin_room.last_activity = datetime.utcnow()
db.session.commit()
flash('Your password reset request has been sent to administrators. They will contact you soon to help reset your password.', 'info')
return redirect(url_for('auth.login'))
return render_template('auth/forgot_password.html', form=form)
# Password reset route
@@ -145,6 +182,50 @@ def reset_password(token):
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)
@auth.route('/change-password', methods=['POST'])
@login_required
def change_password():
"""Change user password"""
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
# Validate inputs
if not all([current_password, new_password, confirm_password]):
flash('All password fields are required.', 'error')
return redirect(url_for('community.profile'))
# Check current password
if not current_user.check_password(current_password):
flash('Current password is incorrect.', 'error')
return redirect(url_for('community.profile'))
# Validate new password
if len(new_password) < 6:
flash('New password must be at least 6 characters long.', 'error')
return redirect(url_for('community.profile'))
# Check password confirmation
if new_password != confirm_password:
flash('New password and confirmation do not match.', 'error')
return redirect(url_for('community.profile'))
# Check if new password is different from current
if current_user.check_password(new_password):
flash('New password must be different from your current password.', 'error')
return redirect(url_for('community.profile'))
try:
# Update password
current_user.set_password(new_password)
db.session.commit()
flash('Password updated successfully!', 'success')
except Exception as e:
db.session.rollback()
flash('An error occurred while updating your password. Please try again.', 'error')
return redirect(url_for('community.profile'))
def is_valid_password(password):
"""Validate password strength"""
if len(password) < 8:

125
app/routes/chat.py Normal file
View File

@@ -0,0 +1,125 @@
"""
Chat web interface routes
Provides HTML templates and endpoints for web-based chat
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from sqlalchemy import desc, and_
from app.extensions import db
from app.models import ChatRoom, ChatMessage, ChatParticipant, Post
# Create blueprint
chat = Blueprint('chat', __name__)
@chat.route('/')
@login_required
def index():
"""Chat main page with room list and rules"""
# Get user's recent chat rooms
user_rooms = db.session.query(ChatRoom).join(ChatParticipant).filter(
and_(
ChatParticipant.user_id == current_user.id,
ChatRoom.is_active == True
)
).order_by(desc(ChatRoom.last_activity)).limit(10).all()
# Get public rooms that are active
public_rooms = ChatRoom.query.filter(
ChatRoom.is_active == True,
ChatRoom.is_private == False
).order_by(desc(ChatRoom.last_activity)).limit(10).all()
return render_template('chat/index.html',
user_rooms=user_rooms,
public_rooms=public_rooms)
@chat.route('/room/<int:room_id>')
@login_required
def room(room_id):
"""Chat room interface"""
room = ChatRoom.query.get_or_404(room_id)
# Check access
if room.is_private:
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant:
flash('You do not have access to this chat room.', 'error')
return redirect(url_for('chat.index'))
# Get or create participant record
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant and not room.is_private:
# Auto-join public rooms
participant = ChatParticipant(
room_id=room_id,
user_id=current_user.id,
role='member'
)
db.session.add(participant)
db.session.commit()
# Get recent messages
messages = ChatMessage.query.filter_by(
room_id=room_id,
is_deleted=False
).order_by(ChatMessage.created_at).limit(50).all()
# Get participants
participants = ChatParticipant.query.filter_by(room_id=room_id).all()
return render_template('chat/room.html',
room=room,
messages=messages,
participants=participants,
current_participant=participant)
@chat.route('/create')
@login_required
def create_room_form():
"""Show create room form"""
# Get available posts for post discussions
recent_posts = Post.query.filter_by(published=True).order_by(
desc(Post.created_at)
).limit(20).all()
return render_template('chat/create_room.html', posts=recent_posts)
@chat.route('/support')
@login_required
def support():
"""Admin support page"""
# Get user's recent support tickets (rooms they created for support)
recent_tickets = ChatRoom.query.filter(
ChatRoom.room_type == 'admin_support',
ChatRoom.created_by_id == current_user.id
).order_by(desc(ChatRoom.created_at)).limit(5).all()
return render_template('chat/support.html',
recent_tickets=recent_tickets)
@chat.route('/embed/<int:post_id>')
@login_required
def embed_post_chat(post_id):
"""Embedded chat widget for post pages"""
post = Post.query.get_or_404(post_id)
# Find existing discussion room
discussion_room = ChatRoom.query.filter(
and_(
ChatRoom.room_type == 'post_discussion',
ChatRoom.related_post_id == post_id,
ChatRoom.is_active == True
)
).first()
return render_template('chat/embed.html',
post=post,
discussion_room=discussion_room)

560
app/routes/chat_api.py Normal file
View File

@@ -0,0 +1,560 @@
"""
Chat API routes for mobile app compatibility
Provides RESTful endpoints for chat functionality
"""
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from sqlalchemy import desc, and_, or_
from datetime import datetime, timedelta
import re
from app.extensions import db
from app.models import ChatRoom, ChatMessage, ChatParticipant, Post, User
# Create blueprint
chat_api = Blueprint('chat_api', __name__)
# Chat rules and guidelines
CHAT_RULES = [
"Be respectful and courteous to all community members",
"No offensive language, harassment, or personal attacks",
"Stay on topic - use post-specific chats for discussions about routes",
"No spam or promotional content without permission",
"Share useful tips and experiences about motorcycle adventures",
"Help newcomers and answer questions when you can",
"Report inappropriate behavior to administrators",
"Keep conversations constructive and helpful"
]
# Profanity filter (basic implementation)
BLOCKED_WORDS = [
'spam', 'scam', 'fake', 'stupid', 'idiot', 'hate'
# Add more words as needed
]
def contains_blocked_content(text):
"""Check if text contains blocked words"""
text_lower = text.lower()
return any(word in text_lower for word in BLOCKED_WORDS)
@chat_api.route('/rules', methods=['GET'])
def get_chat_rules():
"""Get chat rules and guidelines"""
return jsonify({
'success': True,
'rules': CHAT_RULES
})
@chat_api.route('/rooms', methods=['GET'])
@login_required
def get_chat_rooms():
"""Get list of available chat rooms for the user"""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
room_type = request.args.get('type', None)
# Base query - only active rooms the user can access
query = ChatRoom.query.filter(ChatRoom.is_active == True)
# Filter by type if specified
if room_type:
query = query.filter(ChatRoom.room_type == room_type)
# Order by last activity
query = query.order_by(desc(ChatRoom.last_activity))
# Paginate
rooms = query.paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'success': True,
'rooms': [room.to_dict() for room in rooms.items],
'pagination': {
'page': page,
'pages': rooms.pages,
'per_page': per_page,
'total': rooms.total,
'has_next': rooms.has_next,
'has_prev': rooms.has_prev
}
})
@chat_api.route('/rooms', methods=['POST'])
@login_required
def create_chat_room():
"""Create a new chat room"""
data = request.get_json()
if not data or not data.get('name'):
return jsonify({
'success': False,
'error': 'Room name is required'
}), 400
# Validate input
name = data.get('name', '').strip()
description = data.get('description', '').strip()
room_type = data.get('room_type', 'general')
related_post_id = data.get('related_post_id')
if len(name) < 3 or len(name) > 100:
return jsonify({
'success': False,
'error': 'Room name must be between 3 and 100 characters'
}), 400
# Check if room already exists
existing_room = ChatRoom.query.filter_by(name=name).first()
if existing_room:
return jsonify({
'success': False,
'error': 'A room with this name already exists'
}), 400
# Validate related post if specified
if related_post_id:
post = Post.query.get(related_post_id)
if not post:
return jsonify({
'success': False,
'error': 'Related post not found'
}), 404
try:
# Create room
room = ChatRoom(
name=name,
description=description,
room_type=room_type,
created_by_id=current_user.id,
related_post_id=related_post_id
)
db.session.add(room)
db.session.flush() # Get the room ID
# Add creator as participant with admin role
participant = ChatParticipant(
room_id=room.id,
user_id=current_user.id,
role='admin'
)
db.session.add(participant)
# Add system message
system_message = ChatMessage(
room_id=room.id,
user_id=current_user.id,
content=f"Welcome to {name}! This chat room was created for motorcycle adventure discussions.",
message_type='system'
)
db.session.add(system_message)
db.session.commit()
return jsonify({
'success': True,
'room': room.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating chat room: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to create chat room'
}), 500
@chat_api.route('/rooms/<int:room_id>', methods=['GET'])
@login_required
def get_chat_room(room_id):
"""Get chat room details"""
room = ChatRoom.query.get_or_404(room_id)
# Check if user has access
if room.is_private:
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant:
return jsonify({
'success': False,
'error': 'Access denied'
}), 403
return jsonify({
'success': True,
'room': room.to_dict()
})
@chat_api.route('/rooms/<int:room_id>/join', methods=['POST'])
@login_required
def join_chat_room(room_id):
"""Join a chat room"""
room = ChatRoom.query.get_or_404(room_id)
# Check if already a participant
existing_participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if existing_participant:
return jsonify({
'success': True,
'message': 'Already a member of this room'
})
try:
# Add user as participant
participant = ChatParticipant(
room_id=room_id,
user_id=current_user.id,
role='member'
)
db.session.add(participant)
# Add system message
system_message = ChatMessage(
room_id=room_id,
user_id=current_user.id,
content=f"{current_user.nickname} joined the chat",
message_type='system'
)
db.session.add(system_message)
# Update room activity
room.last_activity = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'message': 'Successfully joined the room'
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error joining chat room: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to join room'
}), 500
@chat_api.route('/rooms/<int:room_id>/messages', methods=['GET'])
@login_required
def get_chat_messages(room_id):
"""Get messages from a chat room"""
room = ChatRoom.query.get_or_404(room_id)
# Check access
if room.is_private:
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant:
return jsonify({
'success': False,
'error': 'Access denied'
}), 403
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
# Get messages (newest first for mobile scrolling)
messages = ChatMessage.query.filter_by(
room_id=room_id,
is_deleted=False
).order_by(desc(ChatMessage.created_at)).paginate(
page=page, per_page=per_page, error_out=False
)
# Update user's last seen
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if participant:
participant.last_seen = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'messages': [msg.to_dict() for msg in reversed(messages.items)],
'pagination': {
'page': page,
'pages': messages.pages,
'per_page': per_page,
'total': messages.total,
'has_next': messages.has_next,
'has_prev': messages.has_prev
}
})
@chat_api.route('/rooms/<int:room_id>/messages', methods=['POST'])
@login_required
def send_message(room_id):
"""Send a message to a chat room"""
room = ChatRoom.query.get_or_404(room_id)
data = request.get_json()
if not data or not data.get('content'):
return jsonify({
'success': False,
'error': 'Message content is required'
}), 400
content = data.get('content', '').strip()
message_type = data.get('message_type', 'text')
reply_to_id = data.get('reply_to_id')
# Validate content
if len(content) < 1 or len(content) > 2000:
return jsonify({
'success': False,
'error': 'Message must be between 1 and 2000 characters'
}), 400
# Check for blocked content
if contains_blocked_content(content):
return jsonify({
'success': False,
'error': 'Message contains inappropriate content'
}), 400
# Check if user is participant
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant:
return jsonify({
'success': False,
'error': 'You must join the room first'
}), 403
if participant.is_muted:
return jsonify({
'success': False,
'error': 'You are muted in this room'
}), 403
try:
# Create message
message = ChatMessage(
room_id=room_id,
user_id=current_user.id,
content=content,
message_type=message_type,
reply_to_id=reply_to_id
)
db.session.add(message)
# Update room activity
room.last_activity = datetime.utcnow()
# Update participant last seen
participant.last_seen = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'message': message.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error sending message: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to send message'
}), 500
@chat_api.route('/rooms/<int:room_id>/participants', methods=['GET'])
@login_required
def get_room_participants(room_id):
"""Get participants of a chat room"""
room = ChatRoom.query.get_or_404(room_id)
participants = ChatParticipant.query.filter_by(room_id=room_id).all()
return jsonify({
'success': True,
'participants': [p.to_dict() for p in participants]
})
@chat_api.route('/admin-support', methods=['POST'])
@login_required
def create_admin_support_chat():
"""Create a chat room for admin support (e.g., password reset)"""
data = request.get_json()
reason = data.get('reason', 'general_support')
description = data.get('description', '')
# Check if user already has an active admin support chat
existing_room = ChatRoom.query.filter(
and_(
ChatRoom.room_type == 'admin_support',
ChatRoom.created_by_id == current_user.id,
ChatRoom.is_active == True
)
).first()
if existing_room:
return jsonify({
'success': True,
'room': existing_room.to_dict(),
'message': 'Using existing support chat'
})
try:
# Create admin support room
room_name = f"Support - {current_user.nickname} - {reason}"
room = ChatRoom(
name=room_name,
description=f"Admin support chat for {current_user.nickname}. Reason: {reason}",
room_type='admin_support',
is_private=True,
created_by_id=current_user.id
)
db.session.add(room)
db.session.flush()
# Add user as participant
participant = ChatParticipant(
room_id=room.id,
user_id=current_user.id,
role='member'
)
db.session.add(participant)
# Add all admins as participants
admins = User.query.filter_by(is_admin=True).all()
for admin in admins:
if admin.id != current_user.id:
admin_participant = ChatParticipant(
room_id=room.id,
user_id=admin.id,
role='admin'
)
db.session.add(admin_participant)
# Add initial message
initial_message = ChatMessage(
room_id=room.id,
user_id=current_user.id,
content=f"Hello! I need help with: {reason}. {description}",
message_type='text'
)
db.session.add(initial_message)
db.session.commit()
return jsonify({
'success': True,
'room': room.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating admin support chat: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to create support chat'
}), 500
@chat_api.route('/post/<int:post_id>/discussion', methods=['POST'])
@login_required
def create_post_discussion(post_id):
"""Create or get discussion chat for a specific post"""
post = Post.query.get_or_404(post_id)
# Check if discussion already exists
existing_room = ChatRoom.query.filter(
and_(
ChatRoom.room_type == 'post_discussion',
ChatRoom.related_post_id == post_id,
ChatRoom.is_active == True
)
).first()
if existing_room:
# Join the existing room if not already a participant
participant = ChatParticipant.query.filter_by(
room_id=existing_room.id,
user_id=current_user.id
).first()
if not participant:
participant = ChatParticipant(
room_id=existing_room.id,
user_id=current_user.id,
role='member'
)
db.session.add(participant)
db.session.commit()
return jsonify({
'success': True,
'room': existing_room.to_dict(),
'message': 'Joined existing discussion'
})
try:
# Create new discussion room
room_name = f"Discussion: {post.title}"
room = ChatRoom(
name=room_name,
description=f"Discussion about the post: {post.title}",
room_type='post_discussion',
created_by_id=current_user.id,
related_post_id=post_id
)
db.session.add(room)
db.session.flush()
# Add creator as participant
participant = ChatParticipant(
room_id=room.id,
user_id=current_user.id,
role='moderator'
)
db.session.add(participant)
# Add post author as participant if different
if post.author_id != current_user.id:
author_participant = ChatParticipant(
room_id=room.id,
user_id=post.author_id,
role='moderator'
)
db.session.add(author_participant)
# Add initial message
initial_message = ChatMessage(
room_id=room.id,
user_id=current_user.id,
content=f"Started discussion about: {post.title}",
message_type='system'
)
db.session.add(initial_message)
db.session.commit()
return jsonify({
'success': True,
'room': room.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating post discussion: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to create discussion'
}), 500

View File

@@ -1,27 +1,67 @@
{% extends "base.html" %}
{% block title %}Forgot Password{% endblock %}
{% block title %}Password Reset Request{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50 py-20">
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
<div class="bg-gradient-to-r from-blue-500 via-purple-500 to-teal-500 p-6 text-white text-center">
<h2 class="text-2xl font-bold">Forgot your password?</h2>
<p class="text-blue-100 mt-1">Enter your email to receive a reset link.</p>
</div>
<form method="POST" class="p-6 space-y-6">
{{ form.hidden_tag() }}
<div>
{{ form.email.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.email(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") }}
{% if form.email.errors %}
<div class="text-red-500 text-sm mt-1">
{% for error in form.email.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 py-20">
<div class="max-w-md mx-auto">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-orange-600 to-red-600 p-8 text-white text-center">
<i class="fas fa-key text-4xl mb-4"></i>
<h2 class="text-2xl font-bold">Password Reset Request</h2>
<p class="text-orange-100 mt-2">We'll help you get back into your account</p>
</div>
{{ form.submit(class="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-2 px-4 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition font-medium") }}
</form>
<div class="p-8">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start">
<i class="fas fa-info-circle text-blue-500 mr-3 mt-1"></i>
<div class="text-sm text-blue-700">
<p class="font-semibold mb-1">How it works:</p>
<p>Enter your email address and we'll send a password reset request to our administrators. They will contact you directly to help reset your password securely.</p>
</div>
</div>
</div>
<form method="POST" class="space-y-6">
{{ form.hidden_tag() }}
<div>
{{ form.email.label(class="block text-sm font-semibold text-gray-700 mb-2") }}
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-gray-400"></i>
</div>
{{ form.email(class="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200", placeholder="Enter your email address") }}
</div>
{% if form.email.errors %}
<div class="text-red-500 text-sm mt-2">
{% for error in form.email.errors %}
<p><i class="fas fa-exclamation-circle mr-1"></i>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
{{ form.submit(class="w-full bg-gradient-to-r from-orange-600 to-red-600 text-white py-3 px-6 rounded-lg hover:from-orange-700 hover:to-red-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-all duration-200 font-semibold text-lg") }}
</form>
<div class="mt-8 pt-6 border-t border-gray-200 text-center">
<p class="text-sm text-gray-600 mb-4">Remember your password?</p>
<a href="{{ url_for('auth.login') }}"
class="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium transition-colors duration-200">
<i class="fas fa-arrow-left mr-2"></i>Back to Login
</a>
</div>
<div class="mt-6 bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-start">
<i class="fas fa-shield-alt text-green-500 mr-3 mt-1"></i>
<div class="text-sm text-green-700">
<p class="font-semibold mb-1">Security Note:</p>
<p>Our administrators will verify your identity before resetting your password to keep your account secure.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -21,6 +21,9 @@
</div>
<div class="hidden md:flex items-center space-x-8">
<a href="{{ url_for('community.index') }}" class="text-white hover:text-teal-200 transition font-semibold">🏍️ Adventures</a>
<a href="{{ url_for('chat.index') }}" class="text-white hover:text-purple-200 transition">
<i class="fas fa-comments mr-1"></i>Chat
</a>
<a href="{{ url_for('main.index') }}#accommodation" class="text-white hover:text-purple-200 transition">Accommodation</a>
{% if current_user.is_authenticated %}
{% if not current_user.is_admin %}
@@ -59,6 +62,9 @@
<a href="{{ url_for('main.index') }}#about" class="text-white block px-3 py-2 hover:bg-blue-600 rounded">About</a>
<a href="{{ url_for('main.index') }}#accommodation" class="text-white block px-3 py-2 hover:bg-purple-600 rounded">Accommodation</a>
<a href="{{ url_for('community.index') }}" class="text-white block px-3 py-2 hover:bg-teal-600 rounded">Stories & Tracks</a>
<a href="{{ url_for('chat.index') }}" class="text-white block px-3 py-2 hover:bg-purple-600 rounded">
<i class="fas fa-comments mr-2"></i>Chat
</a>
{% if current_user.is_authenticated %}
{% if not current_user.is_admin %}
<a href="{{ url_for('community.new_post') }}" class="text-white block px-3 py-2 hover:bg-green-600 rounded">
@@ -176,5 +182,7 @@
});
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,313 @@
<!-- Embeddable Chat Widget for Posts and Pages -->
<div class="chat-embed-widget" data-post-id="{{ post.id if post else '' }}" style="margin: 1rem 0;">
<div class="chat-embed-header" onclick="toggleChatEmbed(this)">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="fas fa-comments me-2"></i>
<span class="chat-embed-title">
{% if post %}
Discussion: {{ post.title[:50] }}{% if post.title|length > 50 %}...{% endif %}
{% else %}
Join the Discussion
{% endif %}
</span>
<span class="badge bg-primary ms-2" id="messageCount-{{ post.id if post else 'general' }}">
{{ message_count or 0 }} messages
</span>
</div>
<i class="fas fa-chevron-down chat-embed-toggle"></i>
</div>
</div>
<div class="chat-embed-content" style="display: none;">
<div class="chat-embed-messages" id="embedMessages-{{ post.id if post else 'general' }}">
{% if recent_messages %}
{% for message in recent_messages %}
<div class="chat-embed-message">
<div class="message-header">
<strong>{{ message.user.nickname }}</strong>
{% if message.user.is_admin %}
<span class="badge bg-danger ms-1">ADMIN</span>
{% endif %}
<small class="text-muted ms-2">{{ message.created_at.strftime('%H:%M') }}</small>
</div>
<div class="message-content">{{ message.content }}</div>
</div>
{% endfor %}
{% if message_count > recent_messages|length %}
<div class="text-center mt-2">
<small class="text-muted">+ {{ message_count - recent_messages|length }} more messages</small>
</div>
{% endif %}
{% else %}
<div class="text-center text-muted py-3">
<i class="fas fa-comments fa-2x mb-2"></i>
<p>No messages yet. Start the conversation!</p>
</div>
{% endif %}
</div>
{% if current_user.is_authenticated %}
<div class="chat-embed-input">
<div class="input-group">
<input type="text"
class="form-control"
placeholder="Type your message..."
id="embedInput-{{ post.id if post else 'general' }}"
maxlength="500"
onkeypress="handleEmbedEnter(event, '{{ post.id if post else 'general' }}')">
<button class="btn btn-primary"
type="button"
onclick="sendEmbedMessage('{{ post.id if post else 'general' }}')">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<small class="text-muted">Max 500 characters</small>
</div>
{% else %}
<div class="chat-embed-login text-center py-3">
<p class="text-muted mb-2">Join the discussion</p>
<a href="{{ url_for('auth.login') }}" class="btn btn-primary btn-sm me-2">Login</a>
<a href="{{ url_for('auth.register') }}" class="btn btn-outline-primary btn-sm">Register</a>
</div>
{% endif %}
<div class="chat-embed-actions text-center mt-2">
{% if room_id %}
<a href="{{ url_for('chat.room', room_id=room_id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-expand-alt me-1"></i>
Open Full Chat
</a>
{% endif %}
<a href="{{ url_for('chat.index') }}" class="btn btn-sm btn-outline-secondary ms-2">
<i class="fas fa-comments me-1"></i>
All Chats
</a>
</div>
</div>
</div>
<style>
.chat-embed-widget {
border: 1px solid #e0e0e0;
border-radius: 12px;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.chat-embed-widget:hover {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
}
.chat-embed-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.chat-embed-header:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.chat-embed-title {
font-weight: 600;
}
.chat-embed-toggle {
transition: transform 0.3s ease;
}
.chat-embed-widget.expanded .chat-embed-toggle {
transform: rotate(180deg);
}
.chat-embed-content {
border-top: 1px solid #e0e0e0;
}
.chat-embed-messages {
max-height: 300px;
overflow-y: auto;
padding: 1rem;
background: #f8f9fa;
}
.chat-embed-message {
background: white;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.5rem;
border-left: 3px solid #667eea;
}
.chat-embed-message:last-child {
margin-bottom: 0;
}
.chat-embed-message .message-header {
margin-bottom: 0.25rem;
}
.chat-embed-message .message-content {
color: #495057;
word-wrap: break-word;
}
.chat-embed-input {
padding: 1rem;
background: white;
border-top: 1px solid #e0e0e0;
}
.chat-embed-login {
padding: 1rem;
background: #f8f9fa;
border-top: 1px solid #e0e0e0;
}
.chat-embed-actions {
padding: 0.5rem 1rem 1rem;
background: white;
border-top: 1px solid #e0e0e0;
}
@media (max-width: 768px) {
.chat-embed-widget {
margin: 1rem -15px;
border-radius: 0;
}
.chat-embed-messages {
max-height: 200px;
}
}
</style>
<script>
function toggleChatEmbed(header) {
const widget = header.closest('.chat-embed-widget');
const content = widget.querySelector('.chat-embed-content');
const isExpanded = widget.classList.contains('expanded');
if (isExpanded) {
content.style.display = 'none';
widget.classList.remove('expanded');
} else {
content.style.display = 'block';
widget.classList.add('expanded');
// Load recent messages if not already loaded
const postId = widget.dataset.postId;
if (postId) {
loadEmbedMessages(postId);
}
}
}
function loadEmbedMessages(postId) {
const messagesContainer = document.getElementById(`embedMessages-${postId}`);
fetch(`/api/v1/chat/embed/messages?post_id=${postId}&limit=5`)
.then(response => response.json())
.then(data => {
if (data.success && data.messages.length > 0) {
let messagesHTML = '';
data.messages.forEach(message => {
messagesHTML += `
<div class="chat-embed-message">
<div class="message-header">
<strong>${message.user.nickname}</strong>
${message.user.is_admin ? '<span class="badge bg-danger ms-1">ADMIN</span>' : ''}
<small class="text-muted ms-2">${new Date(message.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</small>
</div>
<div class="message-content">${message.content}</div>
</div>
`;
});
if (data.total_count > data.messages.length) {
messagesHTML += `
<div class="text-center mt-2">
<small class="text-muted">+ ${data.total_count - data.messages.length} more messages</small>
</div>
`;
}
messagesContainer.innerHTML = messagesHTML;
// Update message count
const countBadge = document.getElementById(`messageCount-${postId}`);
if (countBadge) {
countBadge.textContent = `${data.total_count} messages`;
}
}
})
.catch(error => {
console.error('Error loading embed messages:', error);
});
}
function handleEmbedEnter(event, postId) {
if (event.key === 'Enter') {
sendEmbedMessage(postId);
}
}
function sendEmbedMessage(postId) {
const input = document.getElementById(`embedInput-${postId}`);
const content = input.value.trim();
if (!content) return;
// Disable input while sending
input.disabled = true;
fetch('/api/v1/chat/embed/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content,
post_id: postId || null
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
input.value = '';
// Reload messages to show the new one
loadEmbedMessages(postId);
} else {
alert('Error sending message: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to send message');
})
.finally(() => {
input.disabled = false;
input.focus();
});
}
// Auto-load messages when widget is first expanded
document.addEventListener('DOMContentLoaded', function() {
// Add click handlers to all chat embed widgets
document.querySelectorAll('.chat-embed-widget').forEach(widget => {
widget.addEventListener('click', function(e) {
if (e.target.closest('.chat-embed-header')) {
const postId = widget.dataset.postId;
if (postId && widget.classList.contains('expanded')) {
loadEmbedMessages(postId);
}
}
});
});
});
</script>

View File

@@ -0,0 +1,272 @@
{% extends "base.html" %}
{% block title %}Chat - Community Discussions{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Header Section -->
<div class="relative overflow-hidden py-16">
<div class="absolute inset-0 bg-black/20"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-8 border border-white/20">
<h1 class="text-4xl font-bold text-white mb-4">
<i class="fas fa-comments mr-3"></i>Community Chat
</h1>
<p class="text-blue-200 text-lg">Connect with fellow motorcycle adventurers</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-8">
<!-- Community Guidelines Card -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden mb-8">
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-shield-alt text-3xl mr-4"></i>
<div>
<h3 class="text-xl font-bold">Community Guidelines</h3>
<p class="text-green-100">Keep our community safe and welcoming</p>
</div>
</div>
</div>
<div class="p-6 bg-green-50">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-1"></i>
<span>Be respectful to all community members</span>
</div>
<div class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-1"></i>
<span>Share motorcycle adventures and tips</span>
</div>
<div class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-1"></i>
<span>Help others with technical questions</span>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-life-ring text-3xl mr-4"></i>
<div>
<h4 class="text-lg font-bold">Get Support</h4>
<p class="text-blue-100 text-sm">Need help or password reset?</p>
</div>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-4">Contact administrators for support or password reset</p>
<a href="{{ url_for('chat.support') }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-headset mr-2"></i>Get Support
</a>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-plus-circle text-3xl mr-4"></i>
<div>
<h4 class="text-lg font-bold">Create Room</h4>
<p class="text-green-100 text-sm">Start a new discussion</p>
</div>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-4">Start a new chat room on any motorcycle topic</p>
<a href="{{ url_for('chat.create_room_form') }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create Room
</a>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r from-orange-600 to-red-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-key text-3xl mr-4"></i>
<div>
<h4 class="text-lg font-bold">Password Reset</h4>
<p class="text-orange-100 text-sm">Forgot your password?</p>
</div>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-4">Request a password reset from administrators</p>
<a href="{{ url_for('auth.forgot_password') }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-orange-600 to-red-600 text-white font-semibold rounded-lg hover:from-orange-700 hover:to-red-700 transition-all duration-200">
<i class="fas fa-unlock-alt mr-2"></i>Reset Password
</a>
</div>
</div>
</div>
{% if user_rooms %}
<!-- Your Recent Chats -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-white mb-6 flex items-center">
<i class="fas fa-history mr-3"></i>Your Recent Chats
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for room in user_rooms %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r
{% if room.room_type == 'support' %}from-purple-600 to-indigo-600
{% elif room.room_type == 'public' %}from-blue-600 to-cyan-600
{% elif room.room_type == 'group' %}from-green-600 to-teal-600
{% else %}from-gray-600 to-slate-600{% endif %} p-6">
<div class="flex items-center justify-between text-white">
<div>
<h3 class="text-lg font-bold">{{ room.name }}</h3>
<p class="text-sm opacity-80">{{ room.room_type.replace('_', ' ').title() }}</p>
</div>
<i class="fas
{% if room.room_type == 'support' %}fa-headset
{% elif room.room_type == 'public' %}fa-users
{% elif room.room_type == 'group' %}fa-user-friends
{% else %}fa-comments{% endif %} text-2xl"></i>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-4">{{ room.description or 'No description available' }}</p>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>
{{ room.last_activity.strftime('%m/%d %H:%M') if room.last_activity else 'No activity' }}
</span>
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-sign-in-alt mr-2"></i>Join Chat
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if public_rooms %}
<!-- Public Chat Rooms -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-white mb-6 flex items-center">
<i class="fas fa-globe mr-3"></i>Public Chat Rooms
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for room in public_rooms %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r
{% if room.room_type == 'support' %}from-purple-600 to-indigo-600
{% elif room.room_type == 'public' %}from-blue-600 to-cyan-600
{% elif room.room_type == 'group' %}from-green-600 to-teal-600
{% else %}from-gray-600 to-slate-600{% endif %} p-6">
<div class="flex items-center justify-between text-white">
<div>
<h3 class="text-lg font-bold">{{ room.name }}</h3>
<p class="text-sm opacity-80">{{ room.room_type.replace('_', ' ').title() }}</p>
</div>
<div class="text-right">
<i class="fas
{% if room.room_type == 'support' %}fa-headset
{% elif room.room_type == 'public' %}fa-users
{% elif room.room_type == 'group' %}fa-user-friends
{% else %}fa-comments{% endif %} text-2xl"></i>
<p class="text-xs mt-1">{{ room.participants.count() }} members</p>
</div>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-3">{{ room.description or 'Join the conversation!' }}</p>
{% if room.related_post %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<p class="text-sm text-blue-700">
<i class="fas fa-link mr-1"></i>Related to: {{ room.related_post.title }}
</p>
</div>
{% endif %}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>
{{ room.last_activity.strftime('%m/%d %H:%M') if room.last_activity else 'No activity' }}
</span>
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-sign-in-alt mr-2"></i>Join Chat
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if not user_rooms and not public_rooms %}
<!-- Empty State -->
<div class="text-center py-16">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-12 border border-white/20">
<i class="fas fa-comments text-6xl text-white/50 mb-6"></i>
<h3 class="text-2xl font-bold text-white mb-4">No chat rooms available</h3>
<p class="text-blue-200 mb-8">Be the first to start a conversation!</p>
<a href="{{ url_for('chat.create_room_form') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create First Room
</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Mobile app detection and API guidance
if (window.ReactNativeWebView || window.flutter_inappwebview) {
console.log('Mobile app detected - use API endpoints for better performance');
document.body.classList.add('mobile-app-view');
}
// Auto-refresh room list every 60 seconds (increased from 30s to reduce server load)
let refreshInterval;
function startAutoRefresh() {
refreshInterval = setInterval(() => {
if (document.visibilityState === 'visible' && !document.hidden) {
window.location.reload();
}
}, 60000);
}
// Pause refresh when page is hidden
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearInterval(refreshInterval);
} else {
startAutoRefresh();
}
});
// Start auto-refresh on page load
startAutoRefresh();
// Smooth scrolling for better UX
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,413 @@
{% extends "base.html" %}
{% block title %}{{ room.name }} - Chat{% endblock %}
{% block head %}
<style>
.chat-room-container {
height: calc(100vh - 80px);
display: flex;
flex-direction: column;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.chat-header {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
padding: 1rem;
border-bottom: 1px solid #e0e0e0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.chat-main {
flex: 1;
display: flex;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
}
.messages-container {
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
}
.messages-area {
flex: 1;
overflow-y: auto;
padding: 1rem;
background: #f8f9fa;
}
.message {
margin-bottom: 1rem;
animation: fadeIn 0.3s ease-in;
}
.message-header {
display: flex;
align-items: center;
margin-bottom: 0.25rem;
}
.message-author {
font-weight: 600;
color: #495057;
margin-right: 0.5rem;
}
.message-time {
font-size: 0.75rem;
color: #6c757d;
}
.message-content {
background: white;
padding: 0.75rem 1rem;
border-radius: 18px;
border: 1px solid #e9ecef;
max-width: 70%;
word-wrap: break-word;
}
.message.own-message {
text-align: right;
}
.message.own-message .message-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
margin-left: auto;
}
.message.system-message .message-content {
background: #e9ecef;
color: #6c757d;
font-style: italic;
text-align: center;
max-width: 100%;
}
.message-input-area {
padding: 1rem;
background: white;
border-top: 1px solid #e0e0e0;
}
.message-input {
border: 2px solid #e9ecef;
border-radius: 25px;
padding: 0.75rem 1rem;
font-size: 1rem;
transition: all 0.3s ease;
}
.message-input:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.send-button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 50%;
width: 50px;
height: 50px;
color: white;
font-size: 1.2rem;
transition: all 0.3s ease;
}
.send-button:hover {
transform: scale(1.1);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
.participants-sidebar {
width: 250px;
background: white;
border-left: 1px solid #e0e0e0;
padding: 1rem;
overflow-y: auto;
}
.participant-item {
display: flex;
align-items: center;
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: 10px;
transition: background 0.2s ease;
}
.participant-item:hover {
background: #f8f9fa;
}
.participant-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
margin-right: 0.75rem;
}
.participant-info {
flex: 1;
}
.participant-name {
font-weight: 600;
color: #495057;
}
.participant-role {
font-size: 0.75rem;
color: #6c757d;
}
.admin-badge {
background: #dc3545;
color: white;
padding: 0.1rem 0.5rem;
border-radius: 10px;
font-size: 0.6rem;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 768px) {
.participants-sidebar {
display: none;
}
.message-content {
max-width: 85%;
}
}
</style>
{% endblock %}
{% block content %}
<div class="chat-room-container">
<div class="chat-header">
<div class="d-flex align-items-center justify-content-between">
<div>
<h4 class="mb-0">{{ room.name }}</h4>
<small class="text-muted">{{ room.description or 'No description' }}</small>
{% if room.related_post %}
<br><small class="text-primary">Related to: <a href="#" class="text-primary">{{ room.related_post.title }}</a></small>
{% endif %}
</div>
<div>
<a href="{{ url_for('chat.index') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Chats
</a>
</div>
</div>
</div>
<div class="chat-main">
<div class="messages-container">
<div class="messages-area" id="messagesArea">
{% for message in messages %}
<div class="message {% if message.user_id == current_user.id %}own-message{% endif %} {% if message.message_type == 'system' %}system-message{% endif %}">
{% if message.message_type != 'system' %}
<div class="message-header">
<span class="message-author">{{ message.user.nickname }}</span>
{% if message.user.is_admin %}
<span class="admin-badge">ADMIN</span>
{% endif %}
<span class="message-time">{{ message.created_at.strftime('%H:%M') }}</span>
</div>
{% endif %}
<div class="message-content">
{{ message.content }}
{% if message.is_edited %}
<small class="text-muted"> (edited)</small>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<div class="message-input-area">
<form id="messageForm" class="d-flex gap-2">
<input type="text"
id="messageInput"
class="form-control message-input"
placeholder="Type your message..."
maxlength="2000"
autocomplete="off">
<button type="submit" class="send-button">
<i class="fas fa-paper-plane"></i>
</button>
</form>
<small class="text-muted">Press Enter to send • Max 2000 characters</small>
</div>
</div>
<div class="participants-sidebar">
<h6 class="mb-3">Participants ({{ participants|length }})</h6>
{% for participant in participants %}
<div class="participant-item">
<div class="participant-avatar">
{{ participant.user.nickname[0].upper() }}
</div>
<div class="participant-info">
<div class="participant-name">{{ participant.user.nickname }}</div>
<div class="participant-role">
{{ participant.role.title() }}
{% if participant.user.is_admin %}• Admin{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
<script>
const roomId = {{ room.id }};
const currentUserId = {{ current_user.id }};
let lastMessageId = {{ messages[-1].id if messages else 0 }};
// Message form handling
document.getElementById('messageForm').addEventListener('submit', function(e) {
e.preventDefault();
sendMessage();
});
// Enter key handling
document.getElementById('messageInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
function sendMessage() {
const input = document.getElementById('messageInput');
const content = input.value.trim();
if (!content) return;
// Disable input while sending
input.disabled = true;
fetch(`/api/v1/chat/rooms/${roomId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({
content: content,
message_type: 'text'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
input.value = '';
addMessageToUI(data.message);
scrollToBottom();
} else {
alert('Error sending message: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to send message');
})
.finally(() => {
input.disabled = false;
input.focus();
});
}
function addMessageToUI(message) {
const messagesArea = document.getElementById('messagesArea');
const messageDiv = document.createElement('div');
messageDiv.className = `message ${message.user.id === currentUserId ? 'own-message' : ''} ${message.message_type === 'system' ? 'system-message' : ''}`;
let messageHTML = '';
if (message.message_type !== 'system') {
messageHTML += `
<div class="message-header">
<span class="message-author">${message.user.nickname}</span>
${message.user.is_admin ? '<span class="admin-badge">ADMIN</span>' : ''}
<span class="message-time">${new Date(message.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
</div>
`;
}
messageHTML += `
<div class="message-content">
${message.content}
${message.is_edited ? '<small class="text-muted"> (edited)</small>' : ''}
</div>
`;
messageDiv.innerHTML = messageHTML;
messagesArea.appendChild(messageDiv);
lastMessageId = message.id;
}
function scrollToBottom() {
const messagesArea = document.getElementById('messagesArea');
messagesArea.scrollTop = messagesArea.scrollHeight;
}
function loadNewMessages() {
fetch(`/api/v1/chat/rooms/${roomId}/messages?after=${lastMessageId}`)
.then(response => response.json())
.then(data => {
if (data.success && data.messages.length > 0) {
data.messages.forEach(message => {
addMessageToUI(message);
});
scrollToBottom();
}
})
.catch(error => {
console.error('Error loading new messages:', error);
});
}
// Auto-scroll to bottom on load
scrollToBottom();
// Poll for new messages every 3 seconds
setInterval(loadNewMessages, 3000);
// Auto-focus on message input
document.getElementById('messageInput').focus();
// Mobile app integration
if (window.ReactNativeWebView) {
// React Native WebView integration
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'chat_room_opened',
roomId: roomId,
roomName: '{{ room.name }}'
}));
}
// Flutter WebView integration
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('chatRoomOpened', {
roomId: roomId,
roomName: '{{ room.name }}'
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,449 @@
{% extends "base.html" %}
{% block title %}Admin Support - Chat{% endblock %}
{% block head %}
<style>
.support-container {
min-height: calc(100vh - 80px);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem 0;
}
.support-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
margin-bottom: 2rem;
}
.support-header {
text-align: center;
margin-bottom: 2rem;
}
.support-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
margin: 0 auto 1rem;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.action-card {
background: white;
border-radius: 15px;
padding: 1.5rem;
text-align: center;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
text-decoration: none;
color: inherit;
}
.action-card:hover {
border-color: #667eea;
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.15);
text-decoration: none;
color: inherit;
}
.action-icon {
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.2rem;
margin: 0 auto 1rem;
}
.support-form {
background: white;
border-radius: 15px;
padding: 2rem;
border: 2px solid #e9ecef;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-control {
border: 2px solid #e9ecef;
border-radius: 10px;
padding: 0.75rem 1rem;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.priority-selector {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.priority-option {
flex: 1;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 10px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.priority-option.active {
border-color: #667eea;
background: #f8f9ff;
}
.priority-low { border-left: 4px solid #28a745; }
.priority-medium { border-left: 4px solid #ffc107; }
.priority-high { border-left: 4px solid #dc3545; }
.recent-tickets {
background: white;
border-radius: 15px;
padding: 1.5rem;
border: 2px solid #e9ecef;
}
.ticket-item {
display: flex;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e9ecef;
transition: background 0.2s ease;
}
.ticket-item:hover {
background: #f8f9fa;
}
.ticket-item:last-child {
border-bottom: none;
}
.ticket-status {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 1rem;
}
.status-open { background: #28a745; }
.status-pending { background: #ffc107; }
.status-closed { background: #6c757d; }
.ticket-info {
flex: 1;
}
.ticket-title {
font-weight: 600;
color: #495057;
margin-bottom: 0.25rem;
}
.ticket-meta {
font-size: 0.875rem;
color: #6c757d;
}
.btn-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 10px;
padding: 0.75rem 2rem;
color: white;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-gradient:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
color: white;
}
@media (max-width: 768px) {
.support-container {
padding: 1rem;
}
.support-card {
padding: 1rem;
}
.quick-actions {
grid-template-columns: 1fr;
}
.priority-selector {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<div class="support-container">
<div class="container">
<div class="support-card">
<div class="support-header">
<div class="support-icon">
<i class="fas fa-headset"></i>
</div>
<h2>Admin Support</h2>
<p class="text-muted">Get help from our administrators for account issues, password resets, and technical support</p>
</div>
<div class="quick-actions">
<a href="#" class="action-card" onclick="startPasswordReset()">
<div class="action-icon">
<i class="fas fa-key"></i>
</div>
<h5>Password Reset</h5>
<p class="text-muted">Reset your account password with admin assistance</p>
</a>
<a href="#" class="action-card" onclick="startAccountIssue()">
<div class="action-icon">
<i class="fas fa-user-cog"></i>
</div>
<h5>Account Issues</h5>
<p class="text-muted">Login problems, profile updates, and account settings</p>
</a>
<a href="#" class="action-card" onclick="startTechnicalSupport()">
<div class="action-icon">
<i class="fas fa-tools"></i>
</div>
<h5>Technical Support</h5>
<p class="text-muted">App bugs, feature requests, and technical assistance</p>
</a>
<a href="#" class="action-card" onclick="startGeneralInquiry()">
<div class="action-icon">
<i class="fas fa-question-circle"></i>
</div>
<h5>General Inquiry</h5>
<p class="text-muted">Questions about features, policies, or general help</p>
</a>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="support-form">
<h4 class="mb-3">Create Support Ticket</h4>
<form id="supportForm">
<div class="form-group">
<label for="subject" class="form-label">Subject</label>
<input type="text" class="form-control" id="subject" name="subject" required
placeholder="Brief description of your issue">
</div>
<div class="form-group">
<label for="category" class="form-label">Category</label>
<select class="form-control" id="category" name="category" required>
<option value="">Select a category</option>
<option value="password_reset">Password Reset</option>
<option value="account_issues">Account Issues</option>
<option value="technical_support">Technical Support</option>
<option value="general_inquiry">General Inquiry</option>
<option value="bug_report">Bug Report</option>
<option value="feature_request">Feature Request</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Priority</label>
<div class="priority-selector">
<div class="priority-option priority-low" data-priority="low">
<strong>Low</strong><br>
<small>General questions</small>
</div>
<div class="priority-option priority-medium active" data-priority="medium">
<strong>Medium</strong><br>
<small>Account issues</small>
</div>
<div class="priority-option priority-high" data-priority="high">
<strong>High</strong><br>
<small>Urgent problems</small>
</div>
</div>
<input type="hidden" id="priority" name="priority" value="medium">
</div>
<div class="form-group">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="6" required
placeholder="Please provide detailed information about your issue..."></textarea>
</div>
<div class="form-group">
<label for="contactMethod" class="form-label">Preferred Contact Method</label>
<select class="form-control" id="contactMethod" name="contact_method">
<option value="chat">Chat (Recommended)</option>
<option value="email">Email Notification</option>
</select>
</div>
<button type="submit" class="btn btn-gradient btn-lg">
<i class="fas fa-paper-plane me-2"></i>
Submit Support Request
</button>
</form>
</div>
</div>
<div class="col-lg-4">
<div class="recent-tickets">
<h5 class="mb-3">Your Recent Tickets</h5>
{% if recent_tickets %}
{% for ticket in recent_tickets %}
<div class="ticket-item">
<div class="ticket-status status-{{ ticket.status }}"></div>
<div class="ticket-info">
<div class="ticket-title">{{ ticket.subject }}</div>
<div class="ticket-meta">
{{ ticket.created_at.strftime('%b %d, %Y') }} •
{{ ticket.category.replace('_', ' ').title() }}
</div>
</div>
<a href="{{ url_for('chat.room', room_id=ticket.chat_room_id) }}" class="btn btn-sm btn-outline-primary">
View
</a>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">No recent support tickets</p>
{% endif %}
</div>
<div class="recent-tickets mt-3">
<h6 class="mb-3">Support Information</h6>
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<strong>Response Time:</strong> Most tickets are answered within 2-4 hours during business hours.
</div>
<div class="alert alert-warning">
<i class="fas fa-clock"></i>
<strong>Business Hours:</strong> Monday-Friday, 9 AM - 6 PM (Local Time)
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Priority selector handling
document.querySelectorAll('.priority-option').forEach(option => {
option.addEventListener('click', function() {
document.querySelectorAll('.priority-option').forEach(opt => opt.classList.remove('active'));
this.classList.add('active');
document.getElementById('priority').value = this.dataset.priority;
});
});
// Quick action handlers
function startPasswordReset() {
document.getElementById('subject').value = 'Password Reset Request';
document.getElementById('category').value = 'password_reset';
document.getElementById('description').value = 'I need help resetting my password. ';
document.getElementById('description').focus();
}
function startAccountIssue() {
document.getElementById('subject').value = 'Account Issue';
document.getElementById('category').value = 'account_issues';
document.getElementById('description').value = 'I am experiencing issues with my account: ';
document.getElementById('description').focus();
}
function startTechnicalSupport() {
document.getElementById('subject').value = 'Technical Support Request';
document.getElementById('category').value = 'technical_support';
document.getElementById('description').value = 'I need technical assistance with: ';
document.getElementById('description').focus();
}
function startGeneralInquiry() {
document.getElementById('subject').value = 'General Inquiry';
document.getElementById('category').value = 'general_inquiry';
document.getElementById('description').value = 'I have a question about: ';
document.getElementById('description').focus();
}
// Support form submission
document.getElementById('supportForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const submitButton = this.querySelector('button[type="submit"]');
const originalText = submitButton.innerHTML;
// Disable button and show loading
submitButton.disabled = true;
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Creating Ticket...';
fetch('/api/v1/chat/support/create', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Support ticket created successfully! You will be redirected to the chat room.');
window.location.href = `/chat/room/${data.room_id}`;
} else {
alert('Error creating support ticket: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to create support ticket. Please try again.');
})
.finally(() => {
submitButton.disabled = false;
submitButton.innerHTML = originalText;
});
});
// Mobile app integration
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'support_page_opened'
}));
}
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('supportPageOpened');
}
</script>
{% endblock %}

View File

@@ -642,6 +642,9 @@
</div>
</div>
<!-- Chat Discussion Widget -->
{% include 'chat/embed.html' %}
{% endblock %}
{% block scripts %}

View File

@@ -68,6 +68,92 @@
</div>
</div>
<!-- Change Password Card -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden mb-8">
<div class="cursor-pointer transition-all duration-200 hover:bg-gray-50" onclick="togglePasswordCard()">
<div class="p-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-key text-3xl text-purple-600 mr-4"></i>
<div>
<h3 class="text-xl font-bold text-gray-900">Change Password</h3>
<p class="text-gray-600">Update your account password for security</p>
</div>
</div>
<i id="passwordCardToggle" class="fas fa-chevron-down text-gray-400 text-xl transition-transform duration-200"></i>
</div>
</div>
</div>
<!-- Password Change Form (Initially Hidden) -->
<div id="passwordChangeForm" class="hidden border-t border-gray-200">
<div class="p-6 bg-gray-50">
<form id="changePasswordForm" method="POST" action="{{ url_for('auth.change_password') }}" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Current Password -->
<div>
<label for="current_password" class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-lock mr-1"></i>Current Password
</label>
<input type="password"
id="current_password"
name="current_password"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200"
placeholder="Enter current password">
</div>
<!-- New Password -->
<div>
<label for="new_password" class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-key mr-1"></i>New Password
</label>
<input type="password"
id="new_password"
name="new_password"
required
minlength="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200"
placeholder="Enter new password">
</div>
<!-- Confirm New Password -->
<div>
<label for="confirm_password" class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-check-circle mr-1"></i>Confirm Password
</label>
<input type="password"
id="confirm_password"
name="confirm_password"
required
minlength="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200"
placeholder="Confirm new password">
</div>
</div>
<!-- Password Requirements -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 class="font-semibold text-blue-800 mb-2">Password Requirements:</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li><i class="fas fa-check text-green-500 mr-1"></i>At least 6 characters long</li>
<li><i class="fas fa-info-circle text-blue-500 mr-1"></i>Use a unique password you don't use elsewhere</li>
<li><i class="fas fa-shield-alt text-green-500 mr-1"></i>Consider using a mix of letters, numbers, and symbols</li>
</ul>
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white font-semibold rounded-lg hover:from-purple-700 hover:to-blue-700 transition-all duration-200 transform hover:scale-105">
<i class="fas fa-save mr-2"></i>Update Password
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Adventures Collection Header -->
<div class="bg-white rounded-2xl shadow-xl p-6 mb-8">
<div class="flex items-center">
@@ -280,6 +366,67 @@ function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
}
// Password card toggle functionality
function togglePasswordCard() {
const form = document.getElementById('passwordChangeForm');
const toggle = document.getElementById('passwordCardToggle');
if (form && toggle) {
if (form.classList.contains('hidden')) {
form.classList.remove('hidden');
toggle.classList.remove('fa-chevron-down');
toggle.classList.add('fa-chevron-up');
} else {
form.classList.add('hidden');
toggle.classList.remove('fa-chevron-up');
toggle.classList.add('fa-chevron-down');
}
}
}
// Make sure DOM is loaded before attaching event listeners
document.addEventListener('DOMContentLoaded', function() {
// Ensure the toggle function is available globally
window.togglePasswordCard = togglePasswordCard;
});
// Password change form validation
document.getElementById('changePasswordForm').addEventListener('submit', function(e) {
const newPassword = document.getElementById('new_password').value;
const confirmPassword = document.getElementById('confirm_password').value;
if (newPassword !== confirmPassword) {
e.preventDefault();
alert('New password and confirm password do not match. Please try again.');
document.getElementById('confirm_password').focus();
return false;
}
if (newPassword.length < 6) {
e.preventDefault();
alert('Password must be at least 6 characters long.');
document.getElementById('new_password').focus();
return false;
}
});
// Real-time password confirmation validation
document.getElementById('confirm_password').addEventListener('input', function() {
const newPassword = document.getElementById('new_password').value;
const confirmPassword = this.value;
if (confirmPassword && newPassword !== confirmPassword) {
this.style.borderColor = '#ef4444';
this.style.backgroundColor = '#fef2f2';
} else if (confirmPassword && newPassword === confirmPassword) {
this.style.borderColor = '#10b981';
this.style.backgroundColor = '#f0fdf4';
} else {
this.style.borderColor = '#d1d5db';
this.style.backgroundColor = '#ffffff';
}
});
// Close modal when clicking outside
document.getElementById('deleteModal').addEventListener('click', function(e) {
if (e.target === this) {

160
migrations/add_chat_system.py Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Chat System Database Migration
Adds chat functionality to the Moto Adventure application.
"""
import os
import sys
from datetime import datetime
# Add the app directory to the Python path
sys.path.insert(0, '/opt/site')
from app import create_app, db
from app.models import ChatRoom, ChatMessage, ChatParticipant
def run_migration():
"""Run the chat system migration"""
app = create_app()
with app.app_context():
print(f"[{datetime.now()}] Starting chat system migration...")
try:
# Create the chat tables
print("Creating chat system tables...")
db.create_all()
# Get or create system user for welcome messages and room ownership
print("Setting up system user...")
from app.models import User
system_user = User.query.filter_by(email='system@motoadventure.local').first()
if not system_user:
system_user = User(
nickname='System',
email='system@motoadventure.local',
is_admin=True,
is_active=True
)
system_user.set_password('system123!') # Random password, won't be used
db.session.add(system_user)
db.session.commit()
print(" ✓ Created system user")
# Create default chat rooms
print("Creating default chat rooms...")
# General chat room
general_room = ChatRoom.query.filter_by(name="General Discussion").first()
if not general_room:
general_room = ChatRoom(
name="General Discussion",
description="General conversation about motorcycles and adventures",
is_private=False,
is_active=True,
created_by_id=system_user.id
)
db.session.add(general_room)
print(" ✓ Created General Discussion room")
# Technical support room
support_room = ChatRoom.query.filter_by(name="Technical Support").first()
if not support_room:
support_room = ChatRoom(
name="Technical Support",
description="Get help with technical issues and app support",
is_private=False,
is_active=True,
room_type="admin_support",
created_by_id=system_user.id
)
db.session.add(support_room)
print(" ✓ Created Technical Support room")
# Route planning room
routes_room = ChatRoom.query.filter_by(name="Route Planning").first()
if not routes_room:
routes_room = ChatRoom(
name="Route Planning",
description="Discuss routes, share GPX files, and plan adventures",
is_private=False,
is_active=True,
created_by_id=system_user.id
)
db.session.add(routes_room)
print(" ✓ Created Route Planning room")
# Gear & Equipment room
gear_room = ChatRoom.query.filter_by(name="Gear & Equipment").first()
if not gear_room:
gear_room = ChatRoom(
name="Gear & Equipment",
description="Discuss motorcycle gear, equipment reviews, and recommendations",
is_private=False,
is_active=True,
created_by_id=system_user.id
)
db.session.add(gear_room)
print(" ✓ Created Gear & Equipment room")
# Commit the changes
db.session.commit()
print("✓ Default chat rooms created successfully")
# Add welcome messages to rooms
print("Adding welcome messages...")
# Add welcome messages if they don't exist
rooms_with_messages = [
(general_room, "Welcome to the General Discussion! Share your motorcycle adventures and connect with fellow riders."),
(support_room, "Welcome to Technical Support! Our administrators are here to help with any issues or questions."),
(routes_room, "Welcome to Route Planning! Share your favorite routes and discover new adventures."),
(gear_room, "Welcome to Gear & Equipment! Discuss the best gear for your motorcycle adventures.")
]
for room, message_text in rooms_with_messages:
existing_message = ChatMessage.query.filter_by(
room_id=room.id,
user_id=system_user.id,
message_type='system'
).first()
if not existing_message:
welcome_message = ChatMessage(
room_id=room.id,
user_id=system_user.id,
content=message_text,
message_type='system'
)
db.session.add(welcome_message)
db.session.commit()
print("✓ Welcome messages added")
print(f"[{datetime.now()}] Chat system migration completed successfully!")
print("\nChat System Features:")
print(" • User-to-user messaging")
print(" • Admin support channels")
print(" • Post-specific discussions")
print(" • Mobile app compatibility")
print(" • Real-time messaging")
print(" • Profanity filtering")
print(" • Message moderation")
print("\nDefault Chat Rooms:")
print(" • General Discussion")
print(" • Technical Support")
print(" • Route Planning")
print(" • Gear & Equipment")
print("\nAPI Endpoints Available:")
print(" • /api/v1/chat/* (Mobile app integration)")
print(" • /chat/* (Web interface)")
except Exception as e:
print(f"[ERROR] Migration failed: {e}")
db.session.rollback()
raise e
if __name__ == '__main__':
run_migration()