diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..79de4f46545c1e91509dfb76c0d061bf5e92718f 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +uploads/20240321_125655_20250722_140800.jpg filter=lfs diff=lfs merge=lfs -text +uploads/chalan_form_20250722_140229.jpeg filter=lfs diff=lfs merge=lfs -text +uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_142205.jpeg filter=lfs diff=lfs merge=lfs -text +uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_143137.jpeg filter=lfs diff=lfs merge=lfs -text +uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_145007.jpeg filter=lfs diff=lfs merge=lfs -text diff --git a/.replit b/.replit new file mode 100644 index 0000000000000000000000000000000000000000..d3cb833fe9364c37f1ffadcaeceae8adde21eb86 --- /dev/null +++ b/.replit @@ -0,0 +1,34 @@ +modules = ["python-3.11"] + +[nix] +channel = "stable-24_05" +packages = ["openssl", "postgresql"] + +[deployment] +deploymentTarget = "autoscale" +run = ["gunicorn", "--bind", "0.0.0.0:5000", "main:app"] + +[workflows] +runButton = "Project" + +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" + +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" + +[[workflows.workflow]] +name = "Start application" +author = "agent" + +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "gunicorn --bind 0.0.0.0:5000 --reuse-port --reload main:app" +waitForPort = 5000 + +[[ports]] +localPort = 5000 +externalPort = 80 diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..fee32114d5a988ef2339b132faa04ee9f35d845a --- /dev/null +++ b/app.py @@ -0,0 +1,56 @@ +import os +import logging +from flask import Flask +from flask_sqlalchemy import SQLAlchemy +from sqlalchemy.orm import DeclarativeBase +from werkzeug.middleware.proxy_fix import ProxyFix + +# Configure logging +logging.basicConfig(level=logging.DEBUG) + +class Base(DeclarativeBase): + pass + +# Initialize database +db = SQLAlchemy(model_class=Base) + +# Create Flask app +app = Flask(__name__) +app.secret_key = os.environ.get("SESSION_SECRET", "dev-secret-key") +app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1) + +# Configure database +app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DATABASE_URL", "sqlite:///whatsapp_clone.db") +app.config["SQLALCHEMY_ENGINE_OPTIONS"] = { + "pool_recycle": 300, + "pool_pre_ping": True, +} + +# File upload configuration +app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB max file size +app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads') +app.config['ALLOWED_EXTENSIONS'] = { + 'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'psd', 'ai', 'eps', + 'mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac', 'wma', + 'mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', '3gp', 'webm', + 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp', + 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', + 'apk', 'exe', 'dmg', 'deb', 'rpm', 'msi', + 'html', 'css', 'js', 'json', 'xml', 'csv', + 'rtf', 'tex', 'md', 'log' +} + +# Initialize database with app +db.init_app(app) + +# Ensure upload directory exists +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +# Create tables +with app.app_context(): + import models # noqa: F401 + db.create_all() + logging.info("Database tables created") + +# Import routes +import routes # noqa: F401 diff --git a/attached_assets/Pasted-import-os-import-uuid-import-json-from-datetime-import-datetime-from-flask-import-Flask-render--1753191891664_1753191891665.txt b/attached_assets/Pasted-import-os-import-uuid-import-json-from-datetime-import-datetime-from-flask-import-Flask-render--1753191891664_1753191891665.txt new file mode 100644 index 0000000000000000000000000000000000000000..adff3cf308cd654b6eb5800ed7085610dab4c110 --- /dev/null +++ b/attached_assets/Pasted-import-os-import-uuid-import-json-from-datetime-import-datetime-from-flask-import-Flask-render--1753191891664_1753191891665.txt @@ -0,0 +1,428 @@ +import os +import uuid +import json +from datetime import datetime +from flask import Flask, render_template, request, jsonify, session, redirect, url_for +from flask_cors import CORS +import logging + +# Configure logging +logging.basicConfig(level=logging.DEBUG) + +app = Flask(__name__) +app.secret_key = os.environ.get("SESSION_SECRET", "whatsapp-clone-secret-key") +CORS(app) + +# In-memory storage +users = {} # user_id: {name, email, unique_id, online, last_seen} +conversations = {} # conversation_id: {type: 'private'/'group', participants: [], messages: []} +user_conversations = {} # user_id: [conversation_ids] + +def generate_unique_id(): + """Generate a unique 8-character ID for users""" + return str(uuid.uuid4())[:8].upper() + +def get_current_user(): + """Get current user from session""" + user_id = session.get('user_id') + return users.get(user_id) if user_id else None + +@app.route('/') +def landing(): + """Landing page""" + if 'user_id' in session and session['user_id'] in users: + return redirect(url_for('chat')) + return render_template('landing.html') + +@app.route('/register') +def register_page(): + """Registration page""" + return render_template('register.html') + +@app.route('/api/register', methods=['POST']) +def register(): + """Register a new user""" + try: + data = request.get_json() + name = data.get('name', '').strip() + email = data.get('email', '').strip() + + if not name or not email: + return jsonify({'success': False, 'message': 'Name and email are required'}) + + # Check if email already exists + for user_id, user_data in users.items(): + if user_data['email'] == email: + return jsonify({'success': False, 'message': 'Email already registered'}) + + # Create new user + user_id = str(uuid.uuid4()) + unique_id = generate_unique_id() + + users[user_id] = { + 'name': name, + 'email': email, + 'unique_id': unique_id, + 'online': True, + 'last_seen': datetime.now().isoformat(), + 'user_id': user_id + } + + user_conversations[user_id] = [] + + # Set session + session['user_id'] = user_id + + return jsonify({ + 'success': True, + 'user': { + 'user_id': user_id, + 'name': name, + 'email': email, + 'unique_id': unique_id + } + }) + + except Exception as e: + logging.error(f"Registration error: {e}") + return jsonify({'success': False, 'message': 'Registration failed'}) + +@app.route('/chat') +def chat(): + """Main chat interface""" + if 'user_id' not in session or session['user_id'] not in users: + return redirect(url_for('landing')) + + user = users[session['user_id']] + return render_template('chat.html', user=user) + +@app.route('/settings') +def settings(): + """Settings page""" + if 'user_id' not in session or session['user_id'] not in users: + return redirect(url_for('landing')) + + user = users[session['user_id']] + return render_template('settings.html', user=user) + +@app.route('/api/find_user', methods=['POST']) +def find_user(): + """Find user by unique ID""" + try: + data = request.get_json() + unique_id = data.get('unique_id', '').strip().upper() + + if not unique_id: + return jsonify({'success': False, 'message': 'Unique ID is required'}) + + # Find user by unique_id + for user_id, user_data in users.items(): + if user_data['unique_id'] == unique_id: + return jsonify({ + 'success': True, + 'user': { + 'user_id': user_id, + 'name': user_data['name'], + 'unique_id': user_data['unique_id'], + 'online': user_data['online'] + } + }) + + return jsonify({'success': False, 'message': 'User not found'}) + + except Exception as e: + logging.error(f"Find user error: {e}") + return jsonify({'success': False, 'message': 'Search failed'}) + +@app.route('/api/start_conversation', methods=['POST']) +def start_conversation(): + """Start a private conversation or create a group""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + conversation_type = data.get('type') # 'private' or 'group' + + if conversation_type == 'private': + target_user_id = data.get('target_user_id') + + if not target_user_id or target_user_id not in users: + return jsonify({'success': False, 'message': 'Target user not found'}) + + # Check if conversation already exists + for conv_id, conv_data in conversations.items(): + if (conv_data['type'] == 'private' and + set(conv_data['participants']) == {current_user_id, target_user_id}): + return jsonify({'success': True, 'conversation_id': conv_id}) + + # Create new private conversation + conv_id = str(uuid.uuid4()) + conversations[conv_id] = { + 'type': 'private', + 'participants': [current_user_id, target_user_id], + 'messages': [], + 'created_at': datetime.now().isoformat() + } + + # Add to user conversations + if current_user_id not in user_conversations: + user_conversations[current_user_id] = [] + if target_user_id not in user_conversations: + user_conversations[target_user_id] = [] + + user_conversations[current_user_id].append(conv_id) + user_conversations[target_user_id].append(conv_id) + + return jsonify({'success': True, 'conversation_id': conv_id}) + + elif conversation_type == 'group': + group_name = data.get('group_name', '').strip() + participant_ids = data.get('participant_ids', []) + + if not group_name: + return jsonify({'success': False, 'message': 'Group name is required'}) + + if len(participant_ids) < 2 or len(participant_ids) > 9: + return jsonify({'success': False, 'message': 'Groups must have 3-10 members (including you)'}) + + # Add current user to participants + all_participants = list(set([current_user_id] + participant_ids)) + + # Validate all participants exist + for pid in all_participants: + if pid not in users: + return jsonify({'success': False, 'message': f'User {pid} not found'}) + + # Create group conversation + conv_id = str(uuid.uuid4()) + conversations[conv_id] = { + 'type': 'group', + 'name': group_name, + 'participants': all_participants, + 'messages': [], + 'created_at': datetime.now().isoformat(), + 'created_by': current_user_id + } + + # Add to all participants' conversations + for pid in all_participants: + if pid not in user_conversations: + user_conversations[pid] = [] + user_conversations[pid].append(conv_id) + + return jsonify({'success': True, 'conversation_id': conv_id}) + + return jsonify({'success': False, 'message': 'Invalid conversation type'}) + + except Exception as e: + logging.error(f"Start conversation error: {e}") + return jsonify({'success': False, 'message': 'Failed to start conversation'}) + +@app.route('/api/conversations') +def get_conversations(): + """Get user's conversations""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + user_convs = user_conversations.get(current_user_id, []) + + result = [] + for conv_id in user_convs: + if conv_id in conversations: + conv = conversations[conv_id] + + # Get conversation info + conv_info = { + 'id': conv_id, + 'type': conv['type'], + 'participants': [] + } + + if conv['type'] == 'private': + # For private chat, get the other user's info + other_user_id = next(pid for pid in conv['participants'] if pid != current_user_id) + other_user = users.get(other_user_id, {}) + conv_info['name'] = other_user.get('name', 'Unknown User') + conv_info['online'] = other_user.get('online', False) + else: + # For group chat + conv_info['name'] = conv.get('name', 'Group Chat') + conv_info['online'] = True # Groups are always "online" + + # Get participant info + for pid in conv['participants']: + if pid in users: + user_data = users[pid] + conv_info['participants'].append({ + 'user_id': pid, + 'name': user_data['name'], + 'unique_id': user_data['unique_id'], + 'online': user_data['online'] + }) + + # Get last message + if conv['messages']: + last_msg = conv['messages'][-1] + conv_info['last_message'] = { + 'content': last_msg['content'], + 'timestamp': last_msg['timestamp'], + 'sender_name': users.get(last_msg['sender_id'], {}).get('name', 'Unknown') + } + else: + conv_info['last_message'] = None + + result.append(conv_info) + + return jsonify({'success': True, 'conversations': result}) + + except Exception as e: + logging.error(f"Get conversations error: {e}") + return jsonify({'success': False, 'message': 'Failed to load conversations'}) + +@app.route('/api/messages/') +def get_messages(conversation_id): + """Get messages for a conversation""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + + if conversation_id not in conversations: + return jsonify({'success': False, 'message': 'Conversation not found'}) + + conv = conversations[conversation_id] + + # Check if user is participant + if current_user_id not in conv['participants']: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Format messages + messages = [] + for msg in conv['messages']: + sender = users.get(msg['sender_id'], {}) + messages.append({ + 'id': msg['id'], + 'content': msg['content'], + 'sender_id': msg['sender_id'], + 'sender_name': sender.get('name', 'Unknown'), + 'timestamp': msg['timestamp'], + 'status': msg.get('status', 'sent'), + 'seen_by': msg.get('seen_by', []) + }) + + return jsonify({'success': True, 'messages': messages}) + + except Exception as e: + logging.error(f"Get messages error: {e}") + return jsonify({'success': False, 'message': 'Failed to load messages'}) + +@app.route('/api/send_message', methods=['POST']) +def send_message(): + """Send a message""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + + conversation_id = data.get('conversation_id') + content = data.get('content', '').strip() + + if not conversation_id or not content: + return jsonify({'success': False, 'message': 'Conversation ID and content required'}) + + if conversation_id not in conversations: + return jsonify({'success': False, 'message': 'Conversation not found'}) + + conv = conversations[conversation_id] + + # Check if user is participant + if current_user_id not in conv['participants']: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Create message + message = { + 'id': str(uuid.uuid4()), + 'content': content, + 'sender_id': current_user_id, + 'timestamp': datetime.now().isoformat(), + 'status': 'sent', + 'seen_by': [current_user_id] # Sender has seen the message + } + + conversations[conversation_id]['messages'].append(message) + + return jsonify({'success': True, 'message': message}) + + except Exception as e: + logging.error(f"Send message error: {e}") + return jsonify({'success': False, 'message': 'Failed to send message'}) + +@app.route('/api/mark_seen', methods=['POST']) +def mark_seen(): + """Mark messages as seen""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + conversation_id = data.get('conversation_id') + + if not conversation_id or conversation_id not in conversations: + return jsonify({'success': False, 'message': 'Invalid conversation'}) + + conv = conversations[conversation_id] + + # Mark all messages as seen by current user + for message in conv['messages']: + if current_user_id not in message.get('seen_by', []): + message['seen_by'].append(current_user_id) + + return jsonify({'success': True}) + + except Exception as e: + logging.error(f"Mark seen error: {e}") + return jsonify({'success': False, 'message': 'Failed to mark as seen'}) + +@app.route('/api/update_status', methods=['POST']) +def update_status(): + """Update user online status""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + online = data.get('online', True) + + if current_user_id in users: + users[current_user_id]['online'] = online + users[current_user_id]['last_seen'] = datetime.now().isoformat() + + return jsonify({'success': True}) + + except Exception as e: + logging.error(f"Update status error: {e}") + return jsonify({'success': False, 'message': 'Failed to update status'}) + +@app.route('/logout') +def logout(): + """Logout user""" + if 'user_id' in session: + user_id = session['user_id'] + if user_id in users: + users[user_id]['online'] = False + users[user_id]['last_seen'] = datetime.now().isoformat() + session.pop('user_id', None) + + return redirect(url_for('landing')) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/attached_assets/app_1753191931490.py b/attached_assets/app_1753191931490.py new file mode 100644 index 0000000000000000000000000000000000000000..e6afce800bd4c2b9f052d43b48f84cebe89d0255 --- /dev/null +++ b/attached_assets/app_1753191931490.py @@ -0,0 +1,428 @@ +import os +import uuid +import json +from datetime import datetime +from flask import Flask, render_template, request, jsonify, session, redirect, url_for +from flask_cors import CORS +import logging + +# Configure logging +logging.basicConfig(level=logging.DEBUG) + +app = Flask(__name__) +app.secret_key = os.environ.get("SESSION_SECRET", "whatsapp-clone-secret-key") +CORS(app) + +# In-memory storage +users = {} # user_id: {name, email, unique_id, online, last_seen} +conversations = {} # conversation_id: {type: 'private'/'group', participants: [], messages: []} +user_conversations = {} # user_id: [conversation_ids] + +def generate_unique_id(): + """Generate a unique 8-character ID for users""" + return str(uuid.uuid4())[:8].upper() + +def get_current_user(): + """Get current user from session""" + user_id = session.get('user_id') + return users.get(user_id) if user_id else None + +@app.route('/') +def landing(): + """Landing page""" + if 'user_id' in session and session['user_id'] in users: + return redirect(url_for('chat')) + return render_template('landing.html') + +@app.route('/register') +def register_page(): + """Registration page""" + return render_template('register.html') + +@app.route('/api/register', methods=['POST']) +def register(): + """Register a new user""" + try: + data = request.get_json() + name = data.get('name', '').strip() + email = data.get('email', '').strip() + + if not name or not email: + return jsonify({'success': False, 'message': 'Name and email are required'}) + + # Check if email already exists + for user_id, user_data in users.items(): + if user_data['email'] == email: + return jsonify({'success': False, 'message': 'Email already registered'}) + + # Create new user + user_id = str(uuid.uuid4()) + unique_id = generate_unique_id() + + users[user_id] = { + 'name': name, + 'email': email, + 'unique_id': unique_id, + 'online': True, + 'last_seen': datetime.now().isoformat(), + 'user_id': user_id + } + + user_conversations[user_id] = [] + + # Set session + session['user_id'] = user_id + + return jsonify({ + 'success': True, + 'user': { + 'user_id': user_id, + 'name': name, + 'email': email, + 'unique_id': unique_id + } + }) + + except Exception as e: + logging.error(f"Registration error: {e}") + return jsonify({'success': False, 'message': 'Registration failed'}) + +@app.route('/chat') +def chat(): + """Main chat interface""" + if 'user_id' not in session or session['user_id'] not in users: + return redirect(url_for('landing')) + + user = users[session['user_id']] + return render_template('chat.html', user=user) + +@app.route('/settings') +def settings(): + """Settings page""" + if 'user_id' not in session or session['user_id'] not in users: + return redirect(url_for('landing')) + + user = users[session['user_id']] + return render_template('settings.html', user=user) + +@app.route('/api/find_user', methods=['POST']) +def find_user(): + """Find user by unique ID""" + try: + data = request.get_json() + unique_id = data.get('unique_id', '').strip().upper() + + if not unique_id: + return jsonify({'success': False, 'message': 'Unique ID is required'}) + + # Find user by unique_id + for user_id, user_data in users.items(): + if user_data['unique_id'] == unique_id: + return jsonify({ + 'success': True, + 'user': { + 'user_id': user_id, + 'name': user_data['name'], + 'unique_id': user_data['unique_id'], + 'online': user_data['online'] + } + }) + + return jsonify({'success': False, 'message': 'User not found'}) + + except Exception as e: + logging.error(f"Find user error: {e}") + return jsonify({'success': False, 'message': 'Search failed'}) + +@app.route('/api/start_conversation', methods=['POST']) +def start_conversation(): + """Start a private conversation or create a group""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + conversation_type = data.get('type') # 'private' or 'group' + + if conversation_type == 'private': + target_user_id = data.get('target_user_id') + + if not target_user_id or target_user_id not in users: + return jsonify({'success': False, 'message': 'Target user not found'}) + + # Check if conversation already exists + for conv_id, conv_data in conversations.items(): + if (conv_data['type'] == 'private' and + set(conv_data['participants']) == {current_user_id, target_user_id}): + return jsonify({'success': True, 'conversation_id': conv_id}) + + # Create new private conversation + conv_id = str(uuid.uuid4()) + conversations[conv_id] = { + 'type': 'private', + 'participants': [current_user_id, target_user_id], + 'messages': [], + 'created_at': datetime.now().isoformat() + } + + # Add to user conversations + if current_user_id not in user_conversations: + user_conversations[current_user_id] = [] + if target_user_id not in user_conversations: + user_conversations[target_user_id] = [] + + user_conversations[current_user_id].append(conv_id) + user_conversations[target_user_id].append(conv_id) + + return jsonify({'success': True, 'conversation_id': conv_id}) + + elif conversation_type == 'group': + group_name = data.get('group_name', '').strip() + participant_ids = data.get('participant_ids', []) + + if not group_name: + return jsonify({'success': False, 'message': 'Group name is required'}) + + if len(participant_ids) < 2 or len(participant_ids) > 9: + return jsonify({'success': False, 'message': 'Groups must have 3-10 members (including you)'}) + + # Add current user to participants + all_participants = list(set([current_user_id] + participant_ids)) + + # Validate all participants exist + for pid in all_participants: + if pid not in users: + return jsonify({'success': False, 'message': f'User {pid} not found'}) + + # Create group conversation + conv_id = str(uuid.uuid4()) + conversations[conv_id] = { + 'type': 'group', + 'name': group_name, + 'participants': all_participants, + 'messages': [], + 'created_at': datetime.now().isoformat(), + 'created_by': current_user_id + } + + # Add to all participants' conversations + for pid in all_participants: + if pid not in user_conversations: + user_conversations[pid] = [] + user_conversations[pid].append(conv_id) + + return jsonify({'success': True, 'conversation_id': conv_id}) + + return jsonify({'success': False, 'message': 'Invalid conversation type'}) + + except Exception as e: + logging.error(f"Start conversation error: {e}") + return jsonify({'success': False, 'message': 'Failed to start conversation'}) + +@app.route('/api/conversations') +def get_conversations(): + """Get user's conversations""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + user_convs = user_conversations.get(current_user_id, []) + + result = [] + for conv_id in user_convs: + if conv_id in conversations: + conv = conversations[conv_id] + + # Get conversation info + conv_info = { + 'id': conv_id, + 'type': conv['type'], + 'participants': [] + } + + if conv['type'] == 'private': + # For private chat, get the other user's info + other_user_id = next(pid for pid in conv['participants'] if pid != current_user_id) + other_user = users.get(other_user_id, {}) + conv_info['name'] = other_user.get('name', 'Unknown User') + conv_info['online'] = other_user.get('online', False) + else: + # For group chat + conv_info['name'] = conv.get('name', 'Group Chat') + conv_info['online'] = True # Groups are always "online" + + # Get participant info + for pid in conv['participants']: + if pid in users: + user_data = users[pid] + conv_info['participants'].append({ + 'user_id': pid, + 'name': user_data['name'], + 'unique_id': user_data['unique_id'], + 'online': user_data['online'] + }) + + # Get last message + if conv['messages']: + last_msg = conv['messages'][-1] + conv_info['last_message'] = { + 'content': last_msg['content'], + 'timestamp': last_msg['timestamp'], + 'sender_name': users.get(last_msg['sender_id'], {}).get('name', 'Unknown') + } + else: + conv_info['last_message'] = None + + result.append(conv_info) + + return jsonify({'success': True, 'conversations': result}) + + except Exception as e: + logging.error(f"Get conversations error: {e}") + return jsonify({'success': False, 'message': 'Failed to load conversations'}) + +@app.route('/api/messages/') +def get_messages(conversation_id): + """Get messages for a conversation""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + + if conversation_id not in conversations: + return jsonify({'success': False, 'message': 'Conversation not found'}) + + conv = conversations[conversation_id] + + # Check if user is participant + if current_user_id not in conv['participants']: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Format messages + messages = [] + for msg in conv['messages']: + sender = users.get(msg['sender_id'], {}) + messages.append({ + 'id': msg['id'], + 'content': msg['content'], + 'sender_id': msg['sender_id'], + 'sender_name': sender.get('name', 'Unknown'), + 'timestamp': msg['timestamp'], + 'status': msg.get('status', 'sent'), + 'seen_by': msg.get('seen_by', []) + }) + + return jsonify({'success': True, 'messages': messages}) + + except Exception as e: + logging.error(f"Get messages error: {e}") + return jsonify({'success': False, 'message': 'Failed to load messages'}) + +@app.route('/api/send_message', methods=['POST']) +def send_message(): + """Send a message""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + + conversation_id = data.get('conversation_id') + content = data.get('content', '').strip() + + if not conversation_id or not content: + return jsonify({'success': False, 'message': 'Conversation ID and content required'}) + + if conversation_id not in conversations: + return jsonify({'success': False, 'message': 'Conversation not found'}) + + conv = conversations[conversation_id] + + # Check if user is participant + if current_user_id not in conv['participants']: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Create message + message = { + 'id': str(uuid.uuid4()), + 'content': content, + 'sender_id': current_user_id, + 'timestamp': datetime.now().isoformat(), + 'status': 'sent', + 'seen_by': [current_user_id] # Sender has seen the message + } + + conversations[conversation_id]['messages'].append(message) + + return jsonify({'success': True, 'message': message}) + + except Exception as e: + logging.error(f"Send message error: {e}") + return jsonify({'success': False, 'message': 'Failed to send message'}) + +@app.route('/api/mark_seen', methods=['POST']) +def mark_seen(): + """Mark messages as seen""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + conversation_id = data.get('conversation_id') + + if not conversation_id or conversation_id not in conversations: + return jsonify({'success': False, 'message': 'Invalid conversation'}) + + conv = conversations[conversation_id] + + # Mark all messages as seen by current user + for message in conv['messages']: + if current_user_id not in message.get('seen_by', []): + message['seen_by'].append(current_user_id) + + return jsonify({'success': True}) + + except Exception as e: + logging.error(f"Mark seen error: {e}") + return jsonify({'success': False, 'message': 'Failed to mark as seen'}) + +@app.route('/api/update_status', methods=['POST']) +def update_status(): + """Update user online status""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + online = data.get('online', True) + + if current_user_id in users: + users[current_user_id]['online'] = online + users[current_user_id]['last_seen'] = datetime.now().isoformat() + + return jsonify({'success': True}) + + except Exception as e: + logging.error(f"Update status error: {e}") + return jsonify({'success': False, 'message': 'Failed to update status'}) + +@app.route('/logout') +def logout(): + """Logout user""" + if 'user_id' in session: + user_id = session['user_id'] + if user_id in users: + users[user_id]['online'] = False + users[user_id]['last_seen'] = datetime.now().isoformat() + session.pop('user_id', None) + + return redirect(url_for('landing')) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/attached_assets/app_1753194929603.py b/attached_assets/app_1753194929603.py new file mode 100644 index 0000000000000000000000000000000000000000..e6afce800bd4c2b9f052d43b48f84cebe89d0255 --- /dev/null +++ b/attached_assets/app_1753194929603.py @@ -0,0 +1,428 @@ +import os +import uuid +import json +from datetime import datetime +from flask import Flask, render_template, request, jsonify, session, redirect, url_for +from flask_cors import CORS +import logging + +# Configure logging +logging.basicConfig(level=logging.DEBUG) + +app = Flask(__name__) +app.secret_key = os.environ.get("SESSION_SECRET", "whatsapp-clone-secret-key") +CORS(app) + +# In-memory storage +users = {} # user_id: {name, email, unique_id, online, last_seen} +conversations = {} # conversation_id: {type: 'private'/'group', participants: [], messages: []} +user_conversations = {} # user_id: [conversation_ids] + +def generate_unique_id(): + """Generate a unique 8-character ID for users""" + return str(uuid.uuid4())[:8].upper() + +def get_current_user(): + """Get current user from session""" + user_id = session.get('user_id') + return users.get(user_id) if user_id else None + +@app.route('/') +def landing(): + """Landing page""" + if 'user_id' in session and session['user_id'] in users: + return redirect(url_for('chat')) + return render_template('landing.html') + +@app.route('/register') +def register_page(): + """Registration page""" + return render_template('register.html') + +@app.route('/api/register', methods=['POST']) +def register(): + """Register a new user""" + try: + data = request.get_json() + name = data.get('name', '').strip() + email = data.get('email', '').strip() + + if not name or not email: + return jsonify({'success': False, 'message': 'Name and email are required'}) + + # Check if email already exists + for user_id, user_data in users.items(): + if user_data['email'] == email: + return jsonify({'success': False, 'message': 'Email already registered'}) + + # Create new user + user_id = str(uuid.uuid4()) + unique_id = generate_unique_id() + + users[user_id] = { + 'name': name, + 'email': email, + 'unique_id': unique_id, + 'online': True, + 'last_seen': datetime.now().isoformat(), + 'user_id': user_id + } + + user_conversations[user_id] = [] + + # Set session + session['user_id'] = user_id + + return jsonify({ + 'success': True, + 'user': { + 'user_id': user_id, + 'name': name, + 'email': email, + 'unique_id': unique_id + } + }) + + except Exception as e: + logging.error(f"Registration error: {e}") + return jsonify({'success': False, 'message': 'Registration failed'}) + +@app.route('/chat') +def chat(): + """Main chat interface""" + if 'user_id' not in session or session['user_id'] not in users: + return redirect(url_for('landing')) + + user = users[session['user_id']] + return render_template('chat.html', user=user) + +@app.route('/settings') +def settings(): + """Settings page""" + if 'user_id' not in session or session['user_id'] not in users: + return redirect(url_for('landing')) + + user = users[session['user_id']] + return render_template('settings.html', user=user) + +@app.route('/api/find_user', methods=['POST']) +def find_user(): + """Find user by unique ID""" + try: + data = request.get_json() + unique_id = data.get('unique_id', '').strip().upper() + + if not unique_id: + return jsonify({'success': False, 'message': 'Unique ID is required'}) + + # Find user by unique_id + for user_id, user_data in users.items(): + if user_data['unique_id'] == unique_id: + return jsonify({ + 'success': True, + 'user': { + 'user_id': user_id, + 'name': user_data['name'], + 'unique_id': user_data['unique_id'], + 'online': user_data['online'] + } + }) + + return jsonify({'success': False, 'message': 'User not found'}) + + except Exception as e: + logging.error(f"Find user error: {e}") + return jsonify({'success': False, 'message': 'Search failed'}) + +@app.route('/api/start_conversation', methods=['POST']) +def start_conversation(): + """Start a private conversation or create a group""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + conversation_type = data.get('type') # 'private' or 'group' + + if conversation_type == 'private': + target_user_id = data.get('target_user_id') + + if not target_user_id or target_user_id not in users: + return jsonify({'success': False, 'message': 'Target user not found'}) + + # Check if conversation already exists + for conv_id, conv_data in conversations.items(): + if (conv_data['type'] == 'private' and + set(conv_data['participants']) == {current_user_id, target_user_id}): + return jsonify({'success': True, 'conversation_id': conv_id}) + + # Create new private conversation + conv_id = str(uuid.uuid4()) + conversations[conv_id] = { + 'type': 'private', + 'participants': [current_user_id, target_user_id], + 'messages': [], + 'created_at': datetime.now().isoformat() + } + + # Add to user conversations + if current_user_id not in user_conversations: + user_conversations[current_user_id] = [] + if target_user_id not in user_conversations: + user_conversations[target_user_id] = [] + + user_conversations[current_user_id].append(conv_id) + user_conversations[target_user_id].append(conv_id) + + return jsonify({'success': True, 'conversation_id': conv_id}) + + elif conversation_type == 'group': + group_name = data.get('group_name', '').strip() + participant_ids = data.get('participant_ids', []) + + if not group_name: + return jsonify({'success': False, 'message': 'Group name is required'}) + + if len(participant_ids) < 2 or len(participant_ids) > 9: + return jsonify({'success': False, 'message': 'Groups must have 3-10 members (including you)'}) + + # Add current user to participants + all_participants = list(set([current_user_id] + participant_ids)) + + # Validate all participants exist + for pid in all_participants: + if pid not in users: + return jsonify({'success': False, 'message': f'User {pid} not found'}) + + # Create group conversation + conv_id = str(uuid.uuid4()) + conversations[conv_id] = { + 'type': 'group', + 'name': group_name, + 'participants': all_participants, + 'messages': [], + 'created_at': datetime.now().isoformat(), + 'created_by': current_user_id + } + + # Add to all participants' conversations + for pid in all_participants: + if pid not in user_conversations: + user_conversations[pid] = [] + user_conversations[pid].append(conv_id) + + return jsonify({'success': True, 'conversation_id': conv_id}) + + return jsonify({'success': False, 'message': 'Invalid conversation type'}) + + except Exception as e: + logging.error(f"Start conversation error: {e}") + return jsonify({'success': False, 'message': 'Failed to start conversation'}) + +@app.route('/api/conversations') +def get_conversations(): + """Get user's conversations""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + user_convs = user_conversations.get(current_user_id, []) + + result = [] + for conv_id in user_convs: + if conv_id in conversations: + conv = conversations[conv_id] + + # Get conversation info + conv_info = { + 'id': conv_id, + 'type': conv['type'], + 'participants': [] + } + + if conv['type'] == 'private': + # For private chat, get the other user's info + other_user_id = next(pid for pid in conv['participants'] if pid != current_user_id) + other_user = users.get(other_user_id, {}) + conv_info['name'] = other_user.get('name', 'Unknown User') + conv_info['online'] = other_user.get('online', False) + else: + # For group chat + conv_info['name'] = conv.get('name', 'Group Chat') + conv_info['online'] = True # Groups are always "online" + + # Get participant info + for pid in conv['participants']: + if pid in users: + user_data = users[pid] + conv_info['participants'].append({ + 'user_id': pid, + 'name': user_data['name'], + 'unique_id': user_data['unique_id'], + 'online': user_data['online'] + }) + + # Get last message + if conv['messages']: + last_msg = conv['messages'][-1] + conv_info['last_message'] = { + 'content': last_msg['content'], + 'timestamp': last_msg['timestamp'], + 'sender_name': users.get(last_msg['sender_id'], {}).get('name', 'Unknown') + } + else: + conv_info['last_message'] = None + + result.append(conv_info) + + return jsonify({'success': True, 'conversations': result}) + + except Exception as e: + logging.error(f"Get conversations error: {e}") + return jsonify({'success': False, 'message': 'Failed to load conversations'}) + +@app.route('/api/messages/') +def get_messages(conversation_id): + """Get messages for a conversation""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + + if conversation_id not in conversations: + return jsonify({'success': False, 'message': 'Conversation not found'}) + + conv = conversations[conversation_id] + + # Check if user is participant + if current_user_id not in conv['participants']: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Format messages + messages = [] + for msg in conv['messages']: + sender = users.get(msg['sender_id'], {}) + messages.append({ + 'id': msg['id'], + 'content': msg['content'], + 'sender_id': msg['sender_id'], + 'sender_name': sender.get('name', 'Unknown'), + 'timestamp': msg['timestamp'], + 'status': msg.get('status', 'sent'), + 'seen_by': msg.get('seen_by', []) + }) + + return jsonify({'success': True, 'messages': messages}) + + except Exception as e: + logging.error(f"Get messages error: {e}") + return jsonify({'success': False, 'message': 'Failed to load messages'}) + +@app.route('/api/send_message', methods=['POST']) +def send_message(): + """Send a message""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + + conversation_id = data.get('conversation_id') + content = data.get('content', '').strip() + + if not conversation_id or not content: + return jsonify({'success': False, 'message': 'Conversation ID and content required'}) + + if conversation_id not in conversations: + return jsonify({'success': False, 'message': 'Conversation not found'}) + + conv = conversations[conversation_id] + + # Check if user is participant + if current_user_id not in conv['participants']: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Create message + message = { + 'id': str(uuid.uuid4()), + 'content': content, + 'sender_id': current_user_id, + 'timestamp': datetime.now().isoformat(), + 'status': 'sent', + 'seen_by': [current_user_id] # Sender has seen the message + } + + conversations[conversation_id]['messages'].append(message) + + return jsonify({'success': True, 'message': message}) + + except Exception as e: + logging.error(f"Send message error: {e}") + return jsonify({'success': False, 'message': 'Failed to send message'}) + +@app.route('/api/mark_seen', methods=['POST']) +def mark_seen(): + """Mark messages as seen""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + conversation_id = data.get('conversation_id') + + if not conversation_id or conversation_id not in conversations: + return jsonify({'success': False, 'message': 'Invalid conversation'}) + + conv = conversations[conversation_id] + + # Mark all messages as seen by current user + for message in conv['messages']: + if current_user_id not in message.get('seen_by', []): + message['seen_by'].append(current_user_id) + + return jsonify({'success': True}) + + except Exception as e: + logging.error(f"Mark seen error: {e}") + return jsonify({'success': False, 'message': 'Failed to mark as seen'}) + +@app.route('/api/update_status', methods=['POST']) +def update_status(): + """Update user online status""" + try: + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + current_user_id = session['user_id'] + data = request.get_json() + online = data.get('online', True) + + if current_user_id in users: + users[current_user_id]['online'] = online + users[current_user_id]['last_seen'] = datetime.now().isoformat() + + return jsonify({'success': True}) + + except Exception as e: + logging.error(f"Update status error: {e}") + return jsonify({'success': False, 'message': 'Failed to update status'}) + +@app.route('/logout') +def logout(): + """Logout user""" + if 'user_id' in session: + user_id = session['user_id'] + if user_id in users: + users[user_id]['online'] = False + users[user_id]['last_seen'] = datetime.now().isoformat() + session.pop('user_id', None) + + return redirect(url_for('landing')) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/attached_assets/chat_1753191982107.js b/attached_assets/chat_1753191982107.js new file mode 100644 index 0000000000000000000000000000000000000000..0c7217fa0f83aa9c463c67321d37b42e0d68b2d5 --- /dev/null +++ b/attached_assets/chat_1753191982107.js @@ -0,0 +1,662 @@ +// Chat functionality +let conversations = []; +let messages = {}; +let pollingInterval; + +// Mobile sidebar functionality +function toggleMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + + if (sidebar && overlay) { + sidebar.classList.toggle('show'); + overlay.classList.toggle('show'); + + // Prevent body scroll when sidebar is open + if (sidebar.classList.contains('show')) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + } +} + +// Close mobile sidebar when clicking outside +function closeMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + + if (sidebar && overlay) { + sidebar.classList.remove('show'); + overlay.classList.remove('show'); + document.body.style.overflow = ''; + } +} + +// Initialize chat functionality +document.addEventListener('DOMContentLoaded', () => { + if (!document.body.classList.contains('chat-page')) { + return; + } + + initializeChat(); +}); + +async function initializeChat() { + try { + console.log('Initializing chat...'); + + // Clear any existing data + conversations = []; + messages = {}; + window.currentConversation = null; + + // Load fresh data + await loadConversations(); + startPolling(); + setupEventListeners(); + + console.log('Chat initialized successfully'); + } catch (error) { + console.error('Failed to initialize chat:', error); + MainJS.showError('Failed to initialize chat'); + } +} + +function setupEventListeners() { + // Message form + const messageForm = document.getElementById('messageForm'); + if (messageForm) { + messageForm.addEventListener('submit', handleSendMessage); + } + + // Private chat form + const privateChatForm = document.getElementById('privateChatForm'); + if (privateChatForm) { + privateChatForm.addEventListener('submit', handleStartPrivateChat); + } + + // Group chat form + const groupChatForm = document.getElementById('groupChatForm'); + if (groupChatForm) { + groupChatForm.addEventListener('submit', handleCreateGroup); + } +} + +async function loadConversations() { + try { + const response = await MainJS.apiRequest('/api/conversations'); + + if (response.success) { + conversations = response.conversations || []; + renderConversations(); + } else { + console.warn('Failed to load conversations:', response.message); + // Show empty state instead of error for unauthenticated users + conversations = []; + renderConversations(); + } + } catch (error) { + console.error('Failed to load conversations:', error); + conversations = []; + renderConversations(); + } +} + +function renderConversations() { + const conversationsList = document.getElementById('conversationsList'); + if (!conversationsList) { + console.error('Conversations list element not found'); + return; + } + + console.log('Rendering conversations:', conversations); + + // FORCE CLEAR the conversations list first + conversationsList.innerHTML = ''; + + if (conversations.length === 0) { + conversationsList.innerHTML = ` +
+ +

No conversations yet

+ Start a new chat to begin messaging +
+ `; + console.log('No conversations to display'); + return; + } + + conversationsList.innerHTML = conversations.map(conv => { + const lastMessage = conv.last_message; + const isActive = window.currentConversation === conv.id; + + return ` +
+
+
+ ${conv.type === 'group' ? '' : conv.name[0].toUpperCase()} + ${conv.type === 'private' && conv.online ? '
' : ''} +
+
+
+
${MainJS.escapeHtml(conv.name)}
+ ${lastMessage ? `${MainJS.formatTime(lastMessage.timestamp)}` : ''} +
+ ${lastMessage ? ` +
+ ${conv.type === 'group' ? `${MainJS.escapeHtml(lastMessage.sender_name)}: ` : ''} + ${MainJS.escapeHtml(lastMessage.content)} +
+ ` : '
No messages yet
'} + ${conv.type === 'private' && !conv.online ? '
offline
' : ''} +
+
+
+ `; + }).join(''); +} + +async function selectConversation(conversationId) { + try { + console.log('Selecting conversation:', conversationId); + + // Validate that conversation exists + const conversation = conversations.find(c => c.id === conversationId); + if (!conversation) { + console.error('Conversation not found:', conversationId); + MainJS.showError('Conversation not found. Please refresh and try again.'); + return; + } + + window.currentConversation = conversationId; + + // Update UI + document.querySelectorAll('.conversation-item').forEach(item => { + item.classList.remove('active'); + }); + + // Find and activate the clicked conversation + const clickedItem = document.querySelector(`[onclick*="${conversationId}"]`); + if (clickedItem) { + clickedItem.classList.add('active'); + } + + // Show chat container + const welcomeScreen = document.getElementById('welcomeScreen'); + const chatContainer = document.getElementById('chatContainer'); + + if (welcomeScreen) welcomeScreen.style.display = 'none'; + if (chatContainer) { + chatContainer.style.display = 'flex'; + console.log('Chat container displayed'); + } else { + console.error('Chat container not found'); + } + + // Close mobile sidebar when conversation is selected + if (window.innerWidth < 768) { + closeMobileSidebar(); + } + + // Update chat header first + updateChatHeader(conversationId); + + // Load conversation details + console.log('Loading messages for conversation:', conversationId); + await loadMessages(conversationId); + + // Mark messages as seen + markMessagesAsSeen(conversationId); + + console.log('Conversation selected successfully'); + } catch (error) { + console.error('Error selecting conversation:', error); + MainJS.showError('Failed to load conversation'); + } +} + +async function loadMessages(conversationId) { + try { + console.log('Loading messages for conversation ID:', conversationId); + const response = await MainJS.apiRequest(`/api/messages/${conversationId}`); + console.log('Messages API response:', response); + + if (response.success) { + messages[conversationId] = response.messages || []; + console.log('Messages loaded:', response.messages.length); + renderMessages(conversationId); + } else { + console.error('API error:', response.message); + // Even if API fails, show the chat interface with empty state + messages[conversationId] = []; + renderMessages(conversationId); + MainJS.showError('Failed to load messages: ' + response.message); + } + } catch (error) { + console.error('Failed to load messages:', error); + // Show empty chat interface even on error + messages[conversationId] = []; + renderMessages(conversationId); + MainJS.showError('Connection error while loading messages'); + } +} + +function renderMessages(conversationId) { + console.log('Rendering messages for conversation:', conversationId); + const chatMessages = document.getElementById('chatMessages'); + console.log('Chat messages element:', chatMessages); + console.log('Messages data:', messages[conversationId]); + + if (!chatMessages) { + console.error('Chat messages element not found!'); + return; + } + + if (!messages[conversationId]) { + console.error('No messages found for conversation:', conversationId); + return; + } + + const conversationMessages = messages[conversationId]; + console.log('Number of messages to render:', conversationMessages.length); + + if (conversationMessages.length === 0) { + console.log('No messages, showing empty state'); + chatMessages.innerHTML = ` +
+ +

No messages yet

+ Send the first message to start the conversation +
+ `; + return; + } + + chatMessages.innerHTML = conversationMessages.map(msg => { + const isCurrentUser = msg.sender_id === getCurrentUserId(); + const messageClass = isCurrentUser ? 'sent' : 'received'; + + return ` +
+
+ ${!isCurrentUser && getConversationType(conversationId) === 'group' ? + `
${MainJS.escapeHtml(msg.sender_name)}
` : ''} +
+ ${MainJS.escapeHtml(msg.content)} +
+
+ ${MainJS.formatMessageTime(msg.timestamp)} + ${isCurrentUser ? getMessageStatusIcon(msg) : ''} +
+
+
+ `; + }).join(''); + + // Scroll to bottom + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function getMessageStatusIcon(message) { + const seenCount = message.seen_by ? message.seen_by.length : 0; + const conversation = conversations.find(c => c.id === window.currentConversation); + const totalParticipants = conversation ? conversation.participants.length : 1; + + if (seenCount >= totalParticipants) { + return ''; + } else if (seenCount > 1) { + return ''; + } else { + return ''; + } +} + +function updateChatHeader(conversationId) { + const chatHeader = document.getElementById('chatHeader'); + const conversation = conversations.find(c => c.id === conversationId); + + if (!chatHeader || !conversation) return; + + chatHeader.innerHTML = ` +
+
+ ${conversation.type === 'group' ? '' : conversation.name[0].toUpperCase()} + ${conversation.type === 'private' && conversation.online ? '
' : ''} +
+
+
${MainJS.escapeHtml(conversation.name)}
+ + ${conversation.type === 'group' + ? `${conversation.participants.length} members` + : conversation.online ? 'online' : 'offline' + } + +
+
+ `; +} + +async function handleSendMessage(event) { + event.preventDefault(); + + if (!window.currentConversation) { + MainJS.showError('Please select a conversation first'); + return; + } + + const messageInput = document.getElementById('messageInput'); + const content = messageInput.value.trim(); + + if (!content) return; + + try { + const response = await MainJS.apiRequest('/api/send_message', { + method: 'POST', + body: JSON.stringify({ + conversation_id: window.currentConversation, + content: content + }) + }); + + if (response.success) { + messageInput.value = ''; + + // Add message to local state + if (!messages[window.currentConversation]) { + messages[window.currentConversation] = []; + } + messages[window.currentConversation].push(response.message); + + // Re-render messages + renderMessages(window.currentConversation); + + // Update conversations list + await loadConversations(); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('Failed to send message:', error); + MainJS.showError('Failed to send message'); + } +} + +async function markMessagesAsSeen(conversationId) { + try { + await MainJS.apiRequest('/api/mark_seen', { + method: 'POST', + body: JSON.stringify({ + conversation_id: conversationId + }) + }); + } catch (error) { + console.error('Failed to mark messages as seen:', error); + } +} + +function startPolling() { + // Poll for new messages every 2 seconds + pollingInterval = setInterval(async () => { + try { + // Reload conversations to get latest messages + await loadConversations(); + + // If a conversation is selected, reload its messages + if (window.currentConversation) { + await loadMessages(window.currentConversation); + markMessagesAsSeen(window.currentConversation); + } + } catch (error) { + console.error('Polling error:', error); + } + }, 2000); +} + +function stopPolling() { + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + } +} + +// Modal functions +function startPrivateChat() { + const modal = new bootstrap.Modal(document.getElementById('newChatModal')); + modal.hide(); + + setTimeout(() => { + const privateChatModal = new bootstrap.Modal(document.getElementById('privateChatModal')); + privateChatModal.show(); + + // Reset form + document.getElementById('privateChatForm').reset(); + document.getElementById('userPreview').style.display = 'none'; + document.getElementById('startChatBtn').style.display = 'none'; + window.foundUser = null; + }, 300); +} + +function startGroupChat() { + const modal = new bootstrap.Modal(document.getElementById('newChatModal')); + modal.hide(); + + setTimeout(() => { + const groupChatModal = new bootstrap.Modal(document.getElementById('groupChatModal')); + groupChatModal.show(); + + // Reset form + document.getElementById('groupChatForm').reset(); + document.getElementById('groupMembers').innerHTML = ` +
+ + +
+ `; + }, 300); +} + +async function findUser() { + const userIdInput = document.getElementById('userIdInput'); + const uniqueId = userIdInput.value.trim().toUpperCase(); + + if (!uniqueId) { + MainJS.showError('Please enter a user ID'); + return; + } + + try { + const response = await MainJS.apiRequest('/api/find_user', { + method: 'POST', + body: JSON.stringify({ unique_id: uniqueId }) + }); + + if (response.success) { + window.foundUser = response.user; + + const userPreview = document.getElementById('userPreview'); + userPreview.innerHTML = ` +
+
+ ${response.user.name[0].toUpperCase()} +
+
+
${MainJS.escapeHtml(response.user.name)}
+ ${response.user.unique_id} +
+ ${response.user.online ? 'online' : 'offline'} +
+
+
+ `; + userPreview.style.display = 'block'; + document.getElementById('startChatBtn').style.display = 'block'; + } else { + MainJS.showError(response.message); + document.getElementById('userPreview').style.display = 'none'; + document.getElementById('startChatBtn').style.display = 'none'; + window.foundUser = null; + } + } catch (error) { + console.error('Failed to find user:', error); + MainJS.showError('Failed to find user'); + } +} + +async function handleStartPrivateChat(event) { + event.preventDefault(); + + if (!window.foundUser) { + MainJS.showError('Please find a user first'); + return; + } + + try { + const response = await MainJS.apiRequest('/api/start_conversation', { + method: 'POST', + body: JSON.stringify({ + type: 'private', + target_user_id: window.foundUser.user_id + }) + }); + + if (response.success) { + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('privateChatModal')); + modal.hide(); + + // Reload conversations + await loadConversations(); + + // Select the new conversation + selectConversation(response.conversation_id); + + MainJS.showSuccess('Private chat started'); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('Failed to start private chat:', error); + MainJS.showError('Failed to start private chat'); + } +} + +function addMemberField() { + const groupMembers = document.getElementById('groupMembers'); + const memberInputs = groupMembers.querySelectorAll('.member-input'); + + if (memberInputs.length >= 9) { + MainJS.showError('Maximum 9 members allowed'); + return; + } + + const newField = document.createElement('div'); + newField.className = 'input-group mb-2'; + newField.innerHTML = ` + + + `; + + groupMembers.appendChild(newField); +} + +function removeMemberField(button) { + button.closest('.input-group').remove(); +} + +async function handleCreateGroup(event) { + event.preventDefault(); + + const groupNameInput = document.getElementById('groupNameInput'); + const groupName = groupNameInput.value.trim(); + + if (!groupName) { + MainJS.showError('Please enter a group name'); + return; + } + + const memberInputs = document.querySelectorAll('.member-input'); + const memberIds = Array.from(memberInputs) + .map(input => input.value.trim().toUpperCase()) + .filter(id => id.length > 0); + + if (memberIds.length < 2) { + MainJS.showError('Please add at least 2 members'); + return; + } + + if (memberIds.length > 9) { + MainJS.showError('Maximum 9 members allowed'); + return; + } + + try { + // Find all users first to validate + const userPromises = memberIds.map(id => + MainJS.apiRequest('/api/find_user', { + method: 'POST', + body: JSON.stringify({ unique_id: id }) + }) + ); + + const userResults = await Promise.all(userPromises); + const validUsers = []; + + for (let i = 0; i < userResults.length; i++) { + if (userResults[i].success) { + validUsers.push(userResults[i].user.user_id); + } else { + MainJS.showError(`User ${memberIds[i]} not found`); + return; + } + } + + // Create group + const response = await MainJS.apiRequest('/api/start_conversation', { + method: 'POST', + body: JSON.stringify({ + type: 'group', + group_name: groupName, + participant_ids: validUsers + }) + }); + + if (response.success) { + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('groupChatModal')); + modal.hide(); + + // Reload conversations + await loadConversations(); + + // Select the new conversation + selectConversation(response.conversation_id); + + MainJS.showSuccess('Group created successfully'); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('Failed to create group:', error); + MainJS.showError('Failed to create group'); + } +} + +// Helper functions +function getCurrentUserId() { + return window.currentUserId || null; +} + +function getConversationType(conversationId) { + const conversation = conversations.find(c => c.id === conversationId); + return conversation ? conversation.type : 'private'; +} + +// Cleanup on page unload +window.addEventListener('beforeunload', () => { + stopPolling(); +}); diff --git a/attached_assets/chat_1753192043242.html b/attached_assets/chat_1753192043242.html new file mode 100644 index 0000000000000000000000000000000000000000..5d33507510d1b0457d7c3e1084b443a259b50baf --- /dev/null +++ b/attached_assets/chat_1753192043242.html @@ -0,0 +1,212 @@ + + + + + + + + + Chat - WhatsApp Clone + + + + + + + + +
+
+ +
+ +
WhatsApp Clone
+
+
+ + + + + +
+
+
+ +

Welcome to WhatsApp Clone

+

Select a conversation to start messaging

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/attached_assets/chat_1753194964236.html b/attached_assets/chat_1753194964236.html new file mode 100644 index 0000000000000000000000000000000000000000..8862c0e2ce9c1b7be191d619d5af481b410dd4d3 --- /dev/null +++ b/attached_assets/chat_1753194964236.html @@ -0,0 +1,237 @@ + + + + + + + + + Chat - WhatsApp Clone + + + + + + + + +
+
+ +
+ +
WhatsApp Clone
+
+
+ + + + + +
+
+
+ +

Welcome to WhatsApp Clone

+

Select a conversation to start messaging

+
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + diff --git a/attached_assets/chat_1753195002999.js b/attached_assets/chat_1753195002999.js new file mode 100644 index 0000000000000000000000000000000000000000..c9f66efc69e84dbb99dc15d0421a4b4bd941dcea --- /dev/null +++ b/attached_assets/chat_1753195002999.js @@ -0,0 +1,699 @@ +// Chat functionality +let conversations = []; +let messages = {}; +let pollingInterval; + +// Mobile sidebar functionality +function toggleMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + + if (sidebar && overlay) { + sidebar.classList.toggle('show'); + overlay.classList.toggle('show'); + + // Prevent body scroll when sidebar is open + if (sidebar.classList.contains('show')) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + } +} + +// Close mobile sidebar when clicking outside +function closeMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + + if (sidebar && overlay) { + sidebar.classList.remove('show'); + overlay.classList.remove('show'); + document.body.style.overflow = ''; + } +} + +// Initialize chat functionality +document.addEventListener('DOMContentLoaded', () => { + if (!document.body.classList.contains('chat-page')) { + return; + } + + initializeChat(); +}); + +async function initializeChat() { + try { + console.log('Initializing chat...'); + + // Clear any existing data + conversations = []; + messages = {}; + window.currentConversation = null; + + // Load fresh data + await loadConversations(); + startPolling(); + setupEventListeners(); + + console.log('Chat initialized successfully'); + } catch (error) { + console.error('Failed to initialize chat:', error); + MainJS.showError('Failed to initialize chat'); + } +} + +function setupEventListeners() { + // Message form + const messageForm = document.getElementById('messageForm'); + if (messageForm) { + messageForm.addEventListener('submit', handleSendMessage); + } + + // Private chat form + const privateChatForm = document.getElementById('privateChatForm'); + if (privateChatForm) { + privateChatForm.addEventListener('submit', handleStartPrivateChat); + } + + // Group chat form + const groupChatForm = document.getElementById('groupChatForm'); + if (groupChatForm) { + groupChatForm.addEventListener('submit', handleCreateGroup); + } + + // File handling + document.getElementById('fileInput').addEventListener('change', handleFileSelect); +} + +async function loadConversations() { + try { + const response = await MainJS.apiRequest('/api/conversations'); + + if (response.success) { + conversations = response.conversations || []; + renderConversations(); + } else { + console.warn('Failed to load conversations:', response.message); + // Show empty state instead of error for unauthenticated users + conversations = []; + renderConversations(); + } + } catch (error) { + console.error('Failed to load conversations:', error); + conversations = []; + renderConversations(); + } +} + +function renderConversations() { + const conversationsList = document.getElementById('conversationsList'); + if (!conversationsList) { + console.error('Conversations list element not found'); + return; + } + + console.log('Rendering conversations:', conversations); + + // FORCE CLEAR the conversations list first + conversationsList.innerHTML = ''; + + if (conversations.length === 0) { + conversationsList.innerHTML = ` +
+ +

No conversations yet

+ Start a new chat to begin messaging +
+ `; + console.log('No conversations to display'); + return; + } + + conversationsList.innerHTML = conversations.map(conv => { + const lastMessage = conv.last_message; + const isActive = window.currentConversation === conv.id; + + return ` +
+
+
+ ${conv.type === 'group' ? '' : conv.name[0].toUpperCase()} + ${conv.type === 'private' && conv.online ? '
' : ''} +
+
+
+
${MainJS.escapeHtml(conv.name)}
+ ${lastMessage ? `${MainJS.formatTime(lastMessage.timestamp)}` : ''} +
+ ${lastMessage ? ` +
+ ${conv.type === 'group' ? `${MainJS.escapeHtml(lastMessage.sender_name)}: ` : ''} + ${MainJS.escapeHtml(lastMessage.content)} +
+ ` : '
No messages yet
'} + ${conv.type === 'private' && !conv.online ? '
offline
' : ''} +
+
+
+ `; + }).join(''); +} + +async function selectConversation(conversationId) { + try { + console.log('Selecting conversation:', conversationId); + + // Validate that conversation exists + const conversation = conversations.find(c => c.id === conversationId); + if (!conversation) { + console.error('Conversation not found:', conversationId); + MainJS.showError('Conversation not found. Please refresh and try again.'); + return; + } + + window.currentConversation = conversationId; + + // Update UI + document.querySelectorAll('.conversation-item').forEach(item => { + item.classList.remove('active'); + }); + + // Find and activate the clicked conversation + const clickedItem = document.querySelector(`[onclick*="${conversationId}"]`); + if (clickedItem) { + clickedItem.classList.add('active'); + } + + // Show chat container + const welcomeScreen = document.getElementById('welcomeScreen'); + const chatContainer = document.getElementById('chatContainer'); + + if (welcomeScreen) welcomeScreen.style.display = 'none'; + if (chatContainer) { + chatContainer.style.display = 'flex'; + console.log('Chat container displayed'); + } else { + console.error('Chat container not found'); + } + + // Close mobile sidebar when conversation is selected + if (window.innerWidth < 768) { + closeMobileSidebar(); + } + + // Update chat header first + updateChatHeader(conversationId); + + // Load conversation details + console.log('Loading messages for conversation:', conversationId); + await loadMessages(conversationId); + + // Mark messages as seen + markMessagesAsSeen(conversationId); + + console.log('Conversation selected successfully'); + } catch (error) { + console.error('Error selecting conversation:', error); + MainJS.showError('Failed to load conversation'); + } +} + +async function loadMessages(conversationId) { + try { + console.log('Loading messages for conversation ID:', conversationId); + const response = await MainJS.apiRequest(`/api/messages/${conversationId}`); + console.log('Messages API response:', response); + + if (response.success) { + messages[conversationId] = response.messages || []; + console.log('Messages loaded:', response.messages.length); + renderMessages(conversationId); + } else { + console.error('API error:', response.message); + // Even if API fails, show the chat interface with empty state + messages[conversationId] = []; + renderMessages(conversationId); + MainJS.showError('Failed to load messages: ' + response.message); + } + } catch (error) { + console.error('Failed to load messages:', error); + // Show empty chat interface even on error + messages[conversationId] = []; + renderMessages(conversationId); + MainJS.showError('Connection error while loading messages'); + } +} + +function renderMessages(conversationId) { + console.log('Rendering messages for conversation:', conversationId); + const chatMessages = document.getElementById('chatMessages'); + console.log('Chat messages element:', chatMessages); + console.log('Messages data:', messages[conversationId]); + + if (!chatMessages) { + console.error('Chat messages element not found!'); + return; + } + + if (!messages[conversationId]) { + console.error('No messages found for conversation:', conversationId); + return; + } + + const conversationMessages = messages[conversationId]; + console.log('Number of messages to render:', conversationMessages.length); + + if (conversationMessages.length === 0) { + console.log('No messages, showing empty state'); + chatMessages.innerHTML = ` +
+ +

No messages yet

+ Send the first message to start the conversation +
+ `; + return; + } + + chatMessages.innerHTML = conversationMessages.map(msg => { + const isCurrentUser = msg.sender_id === getCurrentUserId(); + const messageClass = isCurrentUser ? 'sent' : 'received'; + + return ` +
+
+ ${!isCurrentUser && getConversationType(conversationId) === 'group' ? + `
${MainJS.escapeHtml(msg.sender_name)}
` : ''} +
+ ${MainJS.escapeHtml(msg.content)} +
+
+ ${MainJS.formatMessageTime(msg.timestamp)} + ${isCurrentUser ? getMessageStatusIcon(msg) : ''} +
+
+
+ `; + }).join(''); + + // Scroll to bottom + chatMessages.scrollTop = chatMessages.scrollHeight; +} + +function getMessageStatusIcon(message) { + const seenCount = message.seen_by ? message.seen_by.length : 0; + const conversation = conversations.find(c => c.id === window.currentConversation); + const totalParticipants = conversation ? conversation.participants.length : 1; + + if (seenCount >= totalParticipants) { + return ''; + } else if (seenCount > 1) { + return ''; + } else { + return ''; + } +} + +function updateChatHeader(conversationId) { + const chatHeader = document.getElementById('chatHeader'); + const conversation = conversations.find(c => c.id === conversationId); + + if (!chatHeader || !conversation) return; + + chatHeader.innerHTML = ` +
+
+ ${conversation.type === 'group' ? '' : conversation.name[0].toUpperCase()} + ${conversation.type === 'private' && conversation.online ? '
' : ''} +
+
+
${MainJS.escapeHtml(conversation.name)}
+ + ${conversation.type === 'group' + ? `${conversation.participants.length} members` + : conversation.online ? 'online' : 'offline' + } + +
+
+ `; +} + +async function handleSendMessage(event) { + event.preventDefault(); + + if (!window.currentConversation) { + MainJS.showError('Please select a conversation first'); + return; + } + + const messageInput = document.getElementById('messageInput'); + const content = messageInput.value.trim(); + + if (!content) return; + + try { + const response = await MainJS.apiRequest('/api/send_message', { + method: 'POST', + body: JSON.stringify({ + conversation_id: window.currentConversation, + content: content + }) + }); + + if (response.success) { + messageInput.value = ''; + + // Add message to local state + if (!messages[window.currentConversation]) { + messages[window.currentConversation] = []; + } + messages[window.currentConversation].push(response.message); + + // Re-render messages + renderMessages(window.currentConversation); + + // Update conversations list + await loadConversations(); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('Failed to send message:', error); + MainJS.showError('Failed to send message'); + } +} + +async function markMessagesAsSeen(conversationId) { + try { + await MainJS.apiRequest('/api/mark_seen', { + method: 'POST', + body: JSON.stringify({ + conversation_id: conversationId + }) + }); + } catch (error) { + console.error('Failed to mark messages as seen:', error); + } +} + +function startPolling() { + // Poll for new messages every 2 seconds + pollingInterval = setInterval(async () => { + try { + // Reload conversations to get latest messages + await loadConversations(); + + // If a conversation is selected, reload its messages + if (window.currentConversation) { + await loadMessages(window.currentConversation); + markMessagesAsSeen(window.currentConversation); + } + } catch (error) { + console.error('Polling error:', error); + } + }, 2000); +} + +function stopPolling() { + if (pollingInterval) { + clearInterval(pollingInterval); + pollingInterval = null; + } +} + +// Modal functions +function startPrivateChat() { + const modal = new bootstrap.Modal(document.getElementById('newChatModal')); + modal.hide(); + + setTimeout(() => { + const privateChatModal = new bootstrap.Modal(document.getElementById('privateChatModal')); + privateChatModal.show(); + + // Reset form + document.getElementById('privateChatForm').reset(); + document.getElementById('userPreview').style.display = 'none'; + document.getElementById('startChatBtn').style.display = 'none'; + window.foundUser = null; + }, 300); +} + +function startGroupChat() { + const modal = new bootstrap.Modal(document.getElementById('newChatModal')); + modal.hide(); + + setTimeout(() => { + const groupChatModal = new bootstrap.Modal(document.getElementById('groupChatModal')); + groupChatModal.show(); + + // Reset form + document.getElementById('groupChatForm').reset(); + document.getElementById('groupMembers').innerHTML = ` +
+ + +
+ `; + }, 300); +} + +async function findUser() { + const userIdInput = document.getElementById('userIdInput'); + const uniqueId = userIdInput.value.trim().toUpperCase(); + + if (!uniqueId) { + MainJS.showError('Please enter a user ID'); + return; + } + + try { + const response = await MainJS.apiRequest('/api/find_user', { + method: 'POST', + body: JSON.stringify({ unique_id: uniqueId }) + }); + + if (response.success) { + window.foundUser = response.user; + + const userPreview = document.getElementById('userPreview'); + userPreview.innerHTML = ` +
+
+ ${response.user.name[0].toUpperCase()} +
+
+
${MainJS.escapeHtml(response.user.name)}
+ ${response.user.unique_id} +
+ ${response.user.online ? 'online' : 'offline'} +
+
+
+ `; + userPreview.style.display = 'block'; + document.getElementById('startChatBtn').style.display = 'block'; + } else { + MainJS.showError(response.message); + document.getElementById('userPreview').style.display = 'none'; + document.getElementById('startChatBtn').style.display = 'none'; + window.foundUser = null; + } + } catch (error) { + console.error('Failed to find user:', error); + MainJS.showError('Failed to find user'); + } +} + +async function handleStartPrivateChat(event) { + event.preventDefault(); + + if (!window.foundUser) { + MainJS.showError('Please find a user first'); + return; + } + + try { + const response = await MainJS.apiRequest('/api/start_conversation', { + method: 'POST', + body: JSON.stringify({ + type: 'private', + target_user_id: window.foundUser.user_id + }) + }); + + if (response.success) { + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('privateChatModal')); + modal.hide(); + + // Reload conversations + await loadConversations(); + + // Select the new conversation + selectConversation(response.conversation_id); + + MainJS.showSuccess('Private chat started'); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('Failed to start private chat:', error); + MainJS.showError('Failed to start private chat'); + } +} + +function addMemberField() { + const groupMembers = document.getElementById('groupMembers'); + const memberInputs = groupMembers.querySelectorAll('.member-input'); + + if (memberInputs.length >= 9) { + MainJS.showError('Maximum 9 members allowed'); + return; + } + + const newField = document.createElement('div'); + newField.className = 'input-group mb-2'; + newField.innerHTML = ` + + + `; + + groupMembers.appendChild(newField); +} + +function removeMemberField(button) { + button.closest('.input-group').remove(); +} + +async function handleCreateGroup(event) { + event.preventDefault(); + + const groupNameInput = document.getElementById('groupNameInput'); + const groupName = groupNameInput.value.trim(); + + if (!groupName) { + MainJS.showError('Please enter a group name'); + return; + } + + const memberInputs = document.querySelectorAll('.member-input'); + const memberIds = Array.from(memberInputs) + .map(input => input.value.trim().toUpperCase()) + .filter(id => id.length > 0); + + if (memberIds.length < 2) { + MainJS.showError('Please add at least 2 members'); + return; + } + + if (memberIds.length > 9) { + MainJS.showError('Maximum 9 members allowed'); + return; + } + + try { + // Find all users first to validate + const userPromises = memberIds.map(id => + MainJS.apiRequest('/api/find_user', { + method: 'POST', + body: JSON.stringify({ unique_id: id }) + }) + ); + + const userResults = await Promise.all(userPromises); + const validUsers = []; + + for (let i = 0; i < userResults.length; i++) { + if (userResults[i].success) { + validUsers.push(userResults[i].user.user_id); + } else { + MainJS.showError(`User ${memberIds[i]} not found`); + return; + } + } + + // Create group + const response = await MainJS.apiRequest('/api/start_conversation', { + method: 'POST', + body: JSON.stringify({ + type: 'group', + group_name: groupName, + participant_ids: validUsers + }) + }); + + if (response.success) { + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('groupChatModal')); + modal.hide(); + + // Reload conversations + await loadConversations(); + + // Select the new conversation + selectConversation(response.conversation_id); + + MainJS.showSuccess('Group created successfully'); + } else { + throw new Error(response.message); + } + } catch (error) { + console.error('Failed to create group:', error); + MainJS.showError('Failed to create group'); + } +} + +// File handling +function handleFileSelect(event) { + const files = event.target.files; + if (files.length === 0) return; + + const conversationId = document.querySelector('#chatContainer').dataset.conversationId; + if (!conversationId) { + console.error('No conversation selected'); + return; + } + + Array.from(files).forEach(file => { + const formData = new FormData(); + formData.append('file', file); + formData.append('conversation_id', conversationId); + + fetch('/api/upload', { + method: 'POST', + body: formData + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + console.log('File uploaded successfully:', data); + event.target.value = ''; + loadMessages(conversationId); + } else { + console.error('File upload failed:', data); + } + }) + .catch(error => console.error('Error uploading file:', error)); + }); +} + +// Helper functions +function getCurrentUserId() { + return window.currentUserId || null; +} + +function getConversationType(conversationId) { + const conversation = conversations.find(c => c.id === conversationId); + return conversation ? conversation.type : 'private'; +} + +// Cleanup on page unload +window.addEventListener('beforeunload', () => { + stopPolling(); +}); diff --git a/attached_assets/landing_1753192060107.html b/attached_assets/landing_1753192060107.html new file mode 100644 index 0000000000000000000000000000000000000000..62c443996d7a3ee1e746145b5d3687e15a7d0d05 --- /dev/null +++ b/attached_assets/landing_1753192060107.html @@ -0,0 +1,80 @@ + + + + + + WhatsApp Clone + + + + + +
+
+
+
+
+ +
+

WhatsApp Clone

+

Connect with friends and family instantly. Send messages, create groups, and stay in touch wherever you are.

+ +
+
+ + Real-time messaging +
+
+ + Group chats (3-10 members) +
+
+ + Message status tracking +
+
+ + Online/offline status +
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
John Doe
+ online +
+
+
+
+
+
Hello there! 👋
+
+
+
Hi! How are you?
+
+
+
I'm doing great! Thanks for asking 😊
+
+
+
+
+
+
+
+
+ + + + diff --git a/attached_assets/landing_1753194964249.html b/attached_assets/landing_1753194964249.html new file mode 100644 index 0000000000000000000000000000000000000000..62c443996d7a3ee1e746145b5d3687e15a7d0d05 --- /dev/null +++ b/attached_assets/landing_1753194964249.html @@ -0,0 +1,80 @@ + + + + + + WhatsApp Clone + + + + + +
+
+
+
+
+ +
+

WhatsApp Clone

+

Connect with friends and family instantly. Send messages, create groups, and stay in touch wherever you are.

+ +
+
+ + Real-time messaging +
+
+ + Group chats (3-10 members) +
+
+ + Message status tracking +
+
+ + Online/offline status +
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
John Doe
+ online +
+
+
+
+
+
Hello there! 👋
+
+
+
Hi! How are you?
+
+
+
I'm doing great! Thanks for asking 😊
+
+
+
+
+
+
+
+
+ + + + diff --git a/attached_assets/main_1753191947010.py b/attached_assets/main_1753191947010.py new file mode 100644 index 0000000000000000000000000000000000000000..85f00c45b2fa2f691eef638ae470fcee713eb2a0 --- /dev/null +++ b/attached_assets/main_1753191947010.py @@ -0,0 +1,4 @@ +from app import app + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/attached_assets/main_1753192006629.js b/attached_assets/main_1753192006629.js new file mode 100644 index 0000000000000000000000000000000000000000..70c13ccc08b120644200d62acc1f52111880241b --- /dev/null +++ b/attached_assets/main_1753192006629.js @@ -0,0 +1,204 @@ +// Main JavaScript utilities and helpers + +// Global variables +window.currentUser = null; +window.currentConversation = null; + +// Utility functions +function formatTime(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + if (diff < 60000) { // Less than 1 minute + return 'now'; + } else if (diff < 3600000) { // Less than 1 hour + return Math.floor(diff / 60000) + 'm'; + } else if (diff < 86400000) { // Less than 1 day + return Math.floor(diff / 3600000) + 'h'; + } else { + return date.toLocaleDateString(); + } +} + +function formatMessageTime(timestamp) { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function showToast(message, type = 'info') { + // Create toast element + const toast = document.createElement('div'); + toast.className = `toast align-items-center text-white bg-${type === 'error' ? 'danger' : 'success'} border-0`; + toast.setAttribute('role', 'alert'); + + toast.innerHTML = ` +
+
+ ${escapeHtml(message)} +
+ +
+ `; + + // Add to page + let toastContainer = document.querySelector('.toast-container'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3'; + document.body.appendChild(toastContainer); + } + + toastContainer.appendChild(toast); + + // Show toast + const bsToast = new bootstrap.Toast(toast); + bsToast.show(); + + // Remove from DOM after hidden + toast.addEventListener('hidden.bs.toast', () => { + toast.remove(); + }); +} + +function showError(message) { + showToast(message, 'error'); +} + +function showSuccess(message) { + showToast(message, 'success'); +} + +// API helper functions +async function apiRequest(url, options = {}) { + try { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('API request failed:', error); + throw error; + } +} + +// Online status management +let statusUpdateInterval; + +function startStatusUpdates() { + // Update online status every 30 seconds + statusUpdateInterval = setInterval(updateOnlineStatus, 30000); + + // Update on page visibility change + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + updateOnlineStatus(); + } + }); + + // Update on page unload + window.addEventListener('beforeunload', () => { + updateOnlineStatus(false); + }); + + // Initial update + updateOnlineStatus(); +} + +async function updateOnlineStatus(online = true) { + try { + await apiRequest('/api/update_status', { + method: 'POST', + body: JSON.stringify({ online }) + }); + } catch (error) { + console.error('Failed to update online status:', error); + } +} + +function stopStatusUpdates() { + if (statusUpdateInterval) { + clearInterval(statusUpdateInterval); + statusUpdateInterval = null; + } +} + +// Initialize common functionality +document.addEventListener('DOMContentLoaded', () => { + // Start status updates if on chat page + if (document.body.classList.contains('chat-page')) { + startStatusUpdates(); + + // Handle mobile keyboard viewport changes + if (/Mobi|Android/i.test(navigator.userAgent)) { + handleMobileViewport(); + } + } + + // Handle page unload + window.addEventListener('beforeunload', () => { + stopStatusUpdates(); + }); +}); + +// Handle mobile viewport changes when keyboard appears/disappears +function handleMobileViewport() { + let initialViewportHeight = window.innerHeight; + + window.addEventListener('resize', () => { + const currentHeight = window.innerHeight; + const chatMessages = document.getElementById('chatMessages'); + + if (chatMessages) { + // If keyboard is shown (viewport shrunk significantly) + if (currentHeight < initialViewportHeight * 0.75) { + // Scroll to bottom when keyboard appears + setTimeout(() => { + chatMessages.scrollTop = chatMessages.scrollHeight; + }, 300); + } + } + }); + + // Handle visual viewport API if available + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', () => { + const chatMessages = document.getElementById('chatMessages'); + if (chatMessages) { + setTimeout(() => { + chatMessages.scrollTop = chatMessages.scrollHeight; + }, 100); + } + }); + } +} + +// Export functions for use in other scripts +window.MainJS = { + formatTime, + formatMessageTime, + escapeHtml, + showToast, + showError, + showSuccess, + apiRequest, + updateOnlineStatus +}; diff --git a/attached_assets/main_1753194943512.py b/attached_assets/main_1753194943512.py new file mode 100644 index 0000000000000000000000000000000000000000..85f00c45b2fa2f691eef638ae470fcee713eb2a0 --- /dev/null +++ b/attached_assets/main_1753194943512.py @@ -0,0 +1,4 @@ +from app import app + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/attached_assets/main_1753195003008.js b/attached_assets/main_1753195003008.js new file mode 100644 index 0000000000000000000000000000000000000000..70c13ccc08b120644200d62acc1f52111880241b --- /dev/null +++ b/attached_assets/main_1753195003008.js @@ -0,0 +1,204 @@ +// Main JavaScript utilities and helpers + +// Global variables +window.currentUser = null; +window.currentConversation = null; + +// Utility functions +function formatTime(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + if (diff < 60000) { // Less than 1 minute + return 'now'; + } else if (diff < 3600000) { // Less than 1 hour + return Math.floor(diff / 60000) + 'm'; + } else if (diff < 86400000) { // Less than 1 day + return Math.floor(diff / 3600000) + 'h'; + } else { + return date.toLocaleDateString(); + } +} + +function formatMessageTime(timestamp) { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function showToast(message, type = 'info') { + // Create toast element + const toast = document.createElement('div'); + toast.className = `toast align-items-center text-white bg-${type === 'error' ? 'danger' : 'success'} border-0`; + toast.setAttribute('role', 'alert'); + + toast.innerHTML = ` +
+
+ ${escapeHtml(message)} +
+ +
+ `; + + // Add to page + let toastContainer = document.querySelector('.toast-container'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3'; + document.body.appendChild(toastContainer); + } + + toastContainer.appendChild(toast); + + // Show toast + const bsToast = new bootstrap.Toast(toast); + bsToast.show(); + + // Remove from DOM after hidden + toast.addEventListener('hidden.bs.toast', () => { + toast.remove(); + }); +} + +function showError(message) { + showToast(message, 'error'); +} + +function showSuccess(message) { + showToast(message, 'success'); +} + +// API helper functions +async function apiRequest(url, options = {}) { + try { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('API request failed:', error); + throw error; + } +} + +// Online status management +let statusUpdateInterval; + +function startStatusUpdates() { + // Update online status every 30 seconds + statusUpdateInterval = setInterval(updateOnlineStatus, 30000); + + // Update on page visibility change + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + updateOnlineStatus(); + } + }); + + // Update on page unload + window.addEventListener('beforeunload', () => { + updateOnlineStatus(false); + }); + + // Initial update + updateOnlineStatus(); +} + +async function updateOnlineStatus(online = true) { + try { + await apiRequest('/api/update_status', { + method: 'POST', + body: JSON.stringify({ online }) + }); + } catch (error) { + console.error('Failed to update online status:', error); + } +} + +function stopStatusUpdates() { + if (statusUpdateInterval) { + clearInterval(statusUpdateInterval); + statusUpdateInterval = null; + } +} + +// Initialize common functionality +document.addEventListener('DOMContentLoaded', () => { + // Start status updates if on chat page + if (document.body.classList.contains('chat-page')) { + startStatusUpdates(); + + // Handle mobile keyboard viewport changes + if (/Mobi|Android/i.test(navigator.userAgent)) { + handleMobileViewport(); + } + } + + // Handle page unload + window.addEventListener('beforeunload', () => { + stopStatusUpdates(); + }); +}); + +// Handle mobile viewport changes when keyboard appears/disappears +function handleMobileViewport() { + let initialViewportHeight = window.innerHeight; + + window.addEventListener('resize', () => { + const currentHeight = window.innerHeight; + const chatMessages = document.getElementById('chatMessages'); + + if (chatMessages) { + // If keyboard is shown (viewport shrunk significantly) + if (currentHeight < initialViewportHeight * 0.75) { + // Scroll to bottom when keyboard appears + setTimeout(() => { + chatMessages.scrollTop = chatMessages.scrollHeight; + }, 300); + } + } + }); + + // Handle visual viewport API if available + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', () => { + const chatMessages = document.getElementById('chatMessages'); + if (chatMessages) { + setTimeout(() => { + chatMessages.scrollTop = chatMessages.scrollHeight; + }, 100); + } + }); + } +} + +// Export functions for use in other scripts +window.MainJS = { + formatTime, + formatMessageTime, + escapeHtml, + showToast, + showError, + showSuccess, + apiRequest, + updateOnlineStatus +}; diff --git a/attached_assets/register_1753192069574.html b/attached_assets/register_1753192069574.html new file mode 100644 index 0000000000000000000000000000000000000000..fa2400bdd5e4e24a65c623adaed96400a53bc918 --- /dev/null +++ b/attached_assets/register_1753192069574.html @@ -0,0 +1,116 @@ + + + + + + Create Account - WhatsApp Clone + + + + + +
+
+
+
+
+ WhatsApp Clone +

Create Account

+

Join our messaging platform

+
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+
+ + + +
+
+
+
+
+ + + + + + diff --git a/attached_assets/register_1753194964249.html b/attached_assets/register_1753194964249.html new file mode 100644 index 0000000000000000000000000000000000000000..fa2400bdd5e4e24a65c623adaed96400a53bc918 --- /dev/null +++ b/attached_assets/register_1753194964249.html @@ -0,0 +1,116 @@ + + + + + + Create Account - WhatsApp Clone + + + + + +
+
+
+
+
+ WhatsApp Clone +

Create Account

+

Join our messaging platform

+
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+
+ + + +
+
+
+
+
+ + + + + + diff --git a/attached_assets/settings_1753192080410.html b/attached_assets/settings_1753192080410.html new file mode 100644 index 0000000000000000000000000000000000000000..c55a3197e83ab2417e141842869fe5c5e4e5ab89 --- /dev/null +++ b/attached_assets/settings_1753192080410.html @@ -0,0 +1,145 @@ + + + + + + Settings - WhatsApp Clone + + + + + +
+
+
+
+
+ + + +

Settings

+
+
+
+
+ {{ user.name[0].upper() }} +
+
{{ user.name }}
+

{{ user.email }}

+
+ +
+
Account Information
+ +
+
+
+
Unique ID
+ Share this ID with others to connect +
+
+ {{ user.unique_id }} + +
+
+
+ +
+
+
+
Name
+ {{ user.name }} +
+ +
+
+ +
+
+
+
Email
+ {{ user.email }} +
+ +
+
+
+ +
+
Actions
+ +
+
+
+
Privacy
+ Manage your privacy settings +
+ +
+
+ +
+
+
+
Notifications
+ Configure notification preferences +
+ +
+
+
+ + +
+
+
+
+
+ + +
+ +
+ + + + + diff --git a/attached_assets/settings_1753194964250.html b/attached_assets/settings_1753194964250.html new file mode 100644 index 0000000000000000000000000000000000000000..c55a3197e83ab2417e141842869fe5c5e4e5ab89 --- /dev/null +++ b/attached_assets/settings_1753194964250.html @@ -0,0 +1,145 @@ + + + + + + Settings - WhatsApp Clone + + + + + +
+
+
+
+
+ + + +

Settings

+
+
+
+
+ {{ user.name[0].upper() }} +
+
{{ user.name }}
+

{{ user.email }}

+
+ +
+
Account Information
+ +
+
+
+
Unique ID
+ Share this ID with others to connect +
+
+ {{ user.unique_id }} + +
+
+
+ +
+
+
+
Name
+ {{ user.name }} +
+ +
+
+ +
+
+
+
Email
+ {{ user.email }} +
+ +
+
+
+ +
+
Actions
+ +
+
+
+
Privacy
+ Manage your privacy settings +
+ +
+
+ +
+
+
+
Notifications
+ Configure notification preferences +
+ +
+
+
+ + +
+
+
+
+
+ + +
+ +
+ + + + + diff --git a/attached_assets/style_1753191963631.css b/attached_assets/style_1753191963631.css new file mode 100644 index 0000000000000000000000000000000000000000..8cd287b11669fa3c4d24ce6adae35bfd908d5097 --- /dev/null +++ b/attached_assets/style_1753191963631.css @@ -0,0 +1,715 @@ +/* General Styles */ +* { + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f0f2f5; + margin: 0; + padding: 0; + height: 100vh; + overflow: hidden; +} + +.h-100 { + height: 100vh !important; +} + +/* Chat Page Layout */ +.chat-page { + height: 100vh; + overflow: hidden; +} + +.chat-page .container-fluid { + height: 100vh; + max-height: 100vh; +} + +.chat-page .row { + height: 100vh; + max-height: 100vh; +} + +.sidebar { + background: white; + border-right: 1px solid #e9ecef; + height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Fix for all screen sizes 769px and above */ +@media (min-width: 769px) { + .chat-page .sidebar { + display: flex !important; + position: relative !important; + height: 100vh !important; + max-height: 100vh !important; + width: 33.33333% !important; + flex: 0 0 auto !important; + } + + .chat-page .chat-area { + display: block !important; + height: 100vh !important; + max-height: 100vh !important; + width: 66.66667% !important; + flex: 0 0 auto !important; + } + + .chat-page .col-md-4 { + width: 33.33333% !important; + flex: 0 0 33.33333% !important; + } + + .chat-page .col-md-8 { + width: 66.66667% !important; + flex: 0 0 66.66667% !important; + } +} + +/* Also fix the md-4 and md-8 at exactly 768px */ +@media (min-width: 768px) and (max-width: 768px) { + .chat-page .sidebar { + display: flex !important; + position: relative !important; + height: 100vh !important; + } + + .chat-page .chat-area { + display: block !important; + height: 100vh !important; + } +} + +.sidebar-header { + background: #25d366; + color: white; + padding: 1rem; + flex-shrink: 0; +} + +.search-box { + padding: 0.5rem; + flex-shrink: 0; + background: #f0f2f5; +} + +.new-chat-btn { + padding: 0.5rem; + flex-shrink: 0; + background: #f0f2f5; +} + +.conversations-list { + flex: 1; + overflow-y: auto; +} + +/* Mobile First Approach */ +html { + font-size: 16px; +} + +@media (max-width: 768px) { + html { + font-size: 14px; + } +} + +/* Landing Page */ +.landing-page { + background: linear-gradient(135deg, #25d366 0%, #128c7e 100%); + color: white; + overflow: auto; + min-height: 100vh; +} + +.landing-content { + max-width: 500px; + padding: 1rem; +} + +.logo { + width: 120px; + height: 120px; + filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2)); +} + +.logo-small { + width: 60px; + height: 60px; +} + +.feature-item { + justify-content: flex-start; + text-align: left; + font-size: 1.1rem; +} + +.phone-mockup { + width: 300px; + height: 600px; + background: #000; + border-radius: 30px; + padding: 20px; + box-shadow: 0 20px 40px rgba(0,0,0,0.3); +} + +.phone-screen { + width: 100%; + height: 100%; + background: white; + border-radius: 20px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.chat-header { + background: #25d366; + color: white; + padding: 15px; + flex-shrink: 0; +} + +.chat-messages { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +.message { + margin-bottom: 15px; +} + +.message.sent { + text-align: right; +} + +.message-bubble { + display: inline-block; + padding: 8px 12px; + border-radius: 18px; + max-width: 80%; + word-wrap: break-word; +} + +.message.received .message-bubble { + background: #f1f1f1; + color: #333; +} + +.message.sent .message-bubble { + background: #dcf8c6; + color: #333; +} + +/* Register Page */ +.register-page { + background: linear-gradient(135deg, #25d366 0%, #128c7e 100%); + overflow: auto; + min-height: 100vh; + padding: 1rem; +} + +.register-card { + background: white; + padding: 2rem; + border-radius: 15px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + width: 100%; + max-width: 500px; + margin: 0 auto; +} + +/* Remove duplicate - already defined above */ + +.conversation-item { + padding: 0.75rem 1rem; + border-bottom: 1px solid #e9ecef; + cursor: pointer; + transition: background-color 0.2s; +} + +.conversation-item:hover { + background-color: #f8f9fa; +} + +.conversation-item.active { + background-color: #e3f2fd; + border-left: 4px solid #25d366; +} + +.chat-area { + background: #e5ddd5; + height: 100vh; + position: relative; + width: 100%; +} + +.welcome-screen { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: url('data:image/svg+xml,') repeat; +} + +.chat-container { + height: 100%; + display: flex; + flex-direction: column; +} + +.chat-header { + background: #25d366; + color: white; + padding: 1rem; + flex-shrink: 0; + border-bottom: 1px solid #128c7e; +} + +.chat-messages { + flex: 1; + padding: 1rem; + overflow-y: auto; + background: #e5ddd5; + background-image: url('data:image/svg+xml,'); +} + +.chat-input { + background: #f0f2f5; + padding: 0.75rem 1rem; + flex-shrink: 0; +} + +.message-item { + margin-bottom: 1rem; + display: flex; + align-items: flex-end; +} + +.message-item.sent { + justify-content: flex-end; +} + +.message-content { + max-width: 70%; + position: relative; +} + +.message-bubble { + padding: 8px 12px; + border-radius: 18px; + word-wrap: break-word; + position: relative; +} + +.message-item.received .message-bubble { + background: white; + color: #333; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); +} + +.message-item.sent .message-bubble { + background: #dcf8c6; + color: #333; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); +} + +.message-time { + font-size: 0.75rem; + color: #666; + margin-top: 4px; + text-align: right; +} + +.message-status { + margin-left: 4px; +} + +.message-status.sent { + color: #999; +} + +.message-status.delivered { + color: #4fc3f7; +} + +.message-status.seen { + color: #25d366; +} + +/* Avatar Styles */ +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + font-size: 1rem; +} + +.avatar-large { + width: 80px; + height: 80px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + font-size: 2rem; +} + +.online-indicator { + width: 12px; + height: 12px; + background: #25d366; + border: 2px solid white; + border-radius: 50%; + position: absolute; + bottom: 0; + right: 0; +} + +/* Settings Page */ +.settings-page { + background: #f0f2f5; + min-height: 100vh; + padding: 2rem 0; +} + +.settings-section { + margin-bottom: 2rem; +} + +.setting-item { + padding: 1rem 0; + border-bottom: 1px solid #e9ecef; +} + +.setting-item:last-child { + border-bottom: none; +} + +/* Responsive Design */ +@media (max-width: 768px) { + body { + overflow-y: auto; + overflow-x: hidden; + } + + .h-100 { + min-height: 100vh; + height: auto; + } + + /* Mobile Chat Layout */ + .chat-page .row { + height: 100vh; + } + + .sidebar { + position: fixed; + top: 0; + left: -100%; + width: 85%; + max-width: 320px; + z-index: 1050; + transition: left 0.3s ease; + box-shadow: 0 0 20px rgba(0,0,0,0.3); + } + + .sidebar.show { + left: 0; + } + + .sidebar-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + z-index: 1040; + display: none; + } + + .sidebar-overlay.show { + display: block; + } + + .chat-area { + width: 100%; + position: relative; + } + + .mobile-header { + display: flex; + align-items: center; + justify-content: space-between; + background: #25d366; + color: white; + padding: 0.75rem 1rem; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .mobile-menu-btn { + background: none; + border: none; + color: white; + font-size: 1.2rem; + cursor: pointer; + } + + /* Landing Page Mobile */ + .phone-mockup { + display: none; + } + + .landing-content { + padding: 2rem 1rem; + } + + .feature-item { + font-size: 1rem; + margin-bottom: 1rem; + } + + /* Register Page Mobile */ + .register-card { + padding: 1.5rem; + margin: 1rem; + border-radius: 10px; + } + + /* Chat Messages Mobile */ + .message-content { + max-width: 85%; + } + + .message-bubble { + font-size: 0.9rem; + padding: 10px 12px; + } + + /* Modal Adjustments */ + .modal-dialog { + margin: 1rem; + max-width: calc(100% - 2rem); + } + + /* Input Adjustments */ + .form-control { + font-size: 16px; /* Prevents zoom on iOS */ + -webkit-appearance: none; + border-radius: 0.375rem; + } + + /* Chat input specific adjustments */ + .chat-input { + padding: 0.5rem 1rem; + border-top: 1px solid #e9ecef; + position: sticky; + bottom: 0; + background: #f0f2f5; + } + + .chat-input .form-control { + border-radius: 25px; + padding: 0.75rem 1rem; + border: 1px solid #ddd; + } + + .chat-input .btn { + border-radius: 50%; + width: 45px; + height: 45px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + } + + /* Avatar Size Adjustments */ + .avatar { + width: 36px; + height: 36px; + font-size: 0.9rem; + } + + .avatar-large { + width: 70px; + height: 70px; + font-size: 1.8rem; + } +} + +/* Extra Small Devices */ +@media (max-width: 576px) { + .sidebar { + width: 100%; + max-width: 100%; + } + + .register-card { + margin: 0.5rem; + padding: 1rem; + } + + .landing-content { + padding: 1.5rem 0.5rem; + } + + .btn { + padding: 0.75rem 1.5rem; + } + + .modal-dialog { + margin: 0.5rem; + max-width: calc(100% - 1rem); + } +} + +/* Landscape Orientation on Small Devices */ +@media (max-height: 500px) and (max-width: 768px) { + .sidebar-header { + padding: 0.5rem 1rem; + } + + .search-box { + padding: 0.5rem 1rem; + } + + .new-chat-btn { + padding: 0.5rem 1rem; + } + + .conversation-item { + padding: 0.5rem 1rem; + } + + .mobile-header { + padding: 0.5rem 1rem; + } +} + +/* Utility Classes */ +.text-online { + color: #25d366 !important; +} + +.text-offline { + color: #999 !important; +} + +.border-success { + border-color: #25d366 !important; +} + +.btn-success { + background-color: #25d366; + border-color: #25d366; +} + +.btn-success:hover { + background-color: #128c7e; + border-color: #128c7e; +} + +.btn-outline-success { + color: #25d366; + border-color: #25d366; +} + +.btn-outline-success:hover { + background-color: #25d366; + border-color: #25d366; +} + +/* Custom Scrollbars */ +.conversations-list::-webkit-scrollbar, +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.conversations-list::-webkit-scrollbar-track, +.chat-messages::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.conversations-list::-webkit-scrollbar-thumb, +.chat-messages::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.conversations-list::-webkit-scrollbar-thumb:hover, +.chat-messages::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* Touch-friendly styles */ +@media (hover: none) and (pointer: coarse) { + .conversation-item:hover { + background-color: transparent; + } + + .conversation-item:active { + background-color: #f8f9fa; + } + + .btn:hover { + transform: none; + } + + .btn:active { + transform: scale(0.98); + } +} + +/* Animation Classes */ +.fade-in { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.slide-in { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { transform: translateX(-20px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* Loading Spinner */ +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid #f3f3f3; + border-top: 2px solid #25d366; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/attached_assets/style_1753194986890.css b/attached_assets/style_1753194986890.css new file mode 100644 index 0000000000000000000000000000000000000000..1d4c73f2db951ca96faab36830e7a436265aa323 --- /dev/null +++ b/attached_assets/style_1753194986890.css @@ -0,0 +1,800 @@ +/* General Styles */ +* { + box-sizing: border-box; +} +/* Recording indicator */ +.recording-dot { + animation: pulse 1s infinite; +} + +@keyframes pulse { + 0% { opacity: 1; } + 50% { opacity: 0.3; } + 100% { opacity: 1; } +} + +/* File message styles */ +.message-file { + display: flex; + align-items: center; + padding: 10px; + background-color: rgba(255, 255, 255, 0.9); + border-radius: 8px; + max-width: 300px; +} + +.file-icon { + font-size: 24px; + margin-right: 10px; +} + +.file-info { + flex-grow: 1; + overflow: hidden; +} + +.file-name { + font-weight: bold; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.file-size { + font-size: 12px; + color: #666; +} + +.download-button { + margin-left: 10px; + color: #075e54; + cursor: pointer; +} + +/* Audio player styles */ +.audio-message { + display: flex; + align-items: center; + padding: 10px; + background-color: rgba(255, 255, 255, 0.9); + border-radius: 8px; + max-width: 300px; +} + +.audio-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.audio-duration { + font-size: 12px; + color: #666; + min-width: 40px; +} + +.audio-progress { + flex-grow: 1; + height: 4px; + background-color: #ddd; + border-radius: 2px; + cursor: pointer; +} + +.audio-progress-bar { + height: 100%; + background-color: #075e54; + border-radius: 2px; + width: 0%; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f0f2f5; + margin: 0; + padding: 0; + height: 100vh; + overflow: hidden; +} + +.h-100 { + height: 100vh !important; +} + +/* Chat Page Layout */ +.chat-page { + height: 100vh; + overflow: hidden; +} + +.chat-page .container-fluid { + height: 100vh; + max-height: 100vh; +} + +.chat-page .row { + height: 100vh; + max-height: 100vh; +} + +.sidebar { + background: white; + border-right: 1px solid #e9ecef; + height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Fix for all screen sizes 769px and above */ +@media (min-width: 769px) { + .chat-page .sidebar { + display: flex !important; + position: relative !important; + height: 100vh !important; + max-height: 100vh !important; + width: 33.33333% !important; + flex: 0 0 auto !important; + } + + .chat-page .chat-area { + display: block !important; + height: 100vh !important; + max-height: 100vh !important; + width: 66.66667% !important; + flex: 0 0 auto !important; + } + + .chat-page .col-md-4 { + width: 33.33333% !important; + flex: 0 0 33.33333% !important; + } + + .chat-page .col-md-8 { + width: 66.66667% !important; + flex: 0 0 66.66667% !important; + } +} + +/* Also fix the md-4 and md-8 at exactly 768px */ +@media (min-width: 768px) and (max-width: 768px) { + .chat-page .sidebar { + display: flex !important; + position: relative !important; + height: 100vh !important; + } + + .chat-page .chat-area { + display: block !important; + height: 100vh !important; + } +} + +.sidebar-header { + background: #25d366; + color: white; + padding: 1rem; + flex-shrink: 0; +} + +.search-box { + padding: 0.5rem; + flex-shrink: 0; + background: #f0f2f5; +} + +.new-chat-btn { + padding: 0.5rem; + flex-shrink: 0; + background: #f0f2f5; +} + +.conversations-list { + flex: 1; + overflow-y: auto; +} + +/* Mobile First Approach */ +html { + font-size: 16px; +} + +@media (max-width: 768px) { + html { + font-size: 14px; + } +} + +/* Landing Page */ +.landing-page { + background: linear-gradient(135deg, #25d366 0%, #128c7e 100%); + color: white; + overflow: auto; + min-height: 100vh; +} + +.landing-content { + max-width: 500px; + padding: 1rem; +} + +.logo { + width: 120px; + height: 120px; + filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2)); +} + +.logo-small { + width: 60px; + height: 60px; +} + +.feature-item { + justify-content: flex-start; + text-align: left; + font-size: 1.1rem; +} + +.phone-mockup { + width: 300px; + height: 600px; + background: #000; + border-radius: 30px; + padding: 20px; + box-shadow: 0 20px 40px rgba(0,0,0,0.3); +} + +.phone-screen { + width: 100%; + height: 100%; + background: white; + border-radius: 20px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.chat-header { + background: #25d366; + color: white; + padding: 15px; + flex-shrink: 0; +} + +.chat-messages { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +.message { + margin-bottom: 15px; +} + +.message.sent { + text-align: right; +} + +.message-bubble { + display: inline-block; + padding: 8px 12px; + border-radius: 18px; + max-width: 80%; + word-wrap: break-word; +} + +.message.received .message-bubble { + background: #f1f1f1; + color: #333; +} + +.message.sent .message-bubble { + background: #dcf8c6; + color: #333; +} + +/* Register Page */ +.register-page { + background: linear-gradient(135deg, #25d366 0%, #128c7e 100%); + overflow: auto; + min-height: 100vh; + padding: 1rem; +} + +.register-card { + background: white; + padding: 2rem; + border-radius: 15px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + width: 100%; + max-width: 500px; + margin: 0 auto; +} + +/* Remove duplicate - already defined above */ + +.conversation-item { + padding: 0.75rem 1rem; + border-bottom: 1px solid #e9ecef; + cursor: pointer; + transition: background-color 0.2s; +} + +.conversation-item:hover { + background-color: #f8f9fa; +} + +.conversation-item.active { + background-color: #e3f2fd; + border-left: 4px solid #25d366; +} + +.chat-area { + background: #e5ddd5; + height: 100vh; + position: relative; + width: 100%; +} + +.welcome-screen { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: url('data:image/svg+xml,') repeat; +} + +.chat-container { + height: 100%; + display: flex; + flex-direction: column; +} + +.chat-header { + background: #25d366; + color: white; + padding: 1rem; + flex-shrink: 0; + border-bottom: 1px solid #128c7e; +} + +.chat-messages { + flex: 1; + padding: 1rem; + overflow-y: auto; + background: #e5ddd5; + background-image: url('data:image/svg+xml,'); +} + +.chat-input { + background: #f0f2f5; + padding: 0.75rem 1rem; + flex-shrink: 0; +} + +.message-item { + margin-bottom: 1rem; + display: flex; + align-items: flex-end; +} + +.message-item.sent { + justify-content: flex-end; +} + +.message-content { + max-width: 70%; + position: relative; +} + +.message-bubble { + padding: 8px 12px; + border-radius: 18px; + word-wrap: break-word; + position: relative; +} + +.message-item.received .message-bubble { + background: white; + color: #333; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); +} + +.message-item.sent .message-bubble { + background: #dcf8c6; + color: #333; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); +} + +.message-time { + font-size: 0.75rem; + color: #666; + margin-top: 4px; + text-align: right; +} + +.message-status { + margin-left: 4px; +} + +.message-status.sent { + color: #999; +} + +.message-status.delivered { + color: #4fc3f7; +} + +.message-status.seen { + color: #25d366; +} + +/* Avatar Styles */ +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + font-size: 1rem; +} + +.avatar-large { + width: 80px; + height: 80px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + font-size: 2rem; +} + +.online-indicator { + width: 12px; + height: 12px; + background: #25d366; + border: 2px solid white; + border-radius: 50%; + position: absolute; + bottom: 0; + right: 0; +} + +/* Settings Page */ +.settings-page { + background: #f0f2f5; + min-height: 100vh; + padding: 2rem 0; +} + +.settings-section { + margin-bottom: 2rem; +} + +.setting-item { + padding: 1rem 0; + border-bottom: 1px solid #e9ecef; +} + +.setting-item:last-child { + border-bottom: none; +} + +/* Responsive Design */ +@media (max-width: 768px) { + body { + overflow-y: auto; + overflow-x: hidden; + } + + .h-100 { + min-height: 100vh; + height: auto; + } + + /* Mobile Chat Layout */ + .chat-page .row { + height: 100vh; + } + + .sidebar { + position: fixed; + top: 0; + left: -100%; + width: 85%; + max-width: 320px; + z-index: 1050; + transition: left 0.3s ease; + box-shadow: 0 0 20px rgba(0,0,0,0.3); + } + + .sidebar.show { + left: 0; + } + + .sidebar-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + z-index: 1040; + display: none; + } + + .sidebar-overlay.show { + display: block; + } + + .chat-area { + width: 100%; + position: relative; + } + + .mobile-header { + display: flex; + align-items: center; + justify-content: space-between; + background: #25d366; + color: white; + padding: 0.75rem 1rem; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .mobile-menu-btn { + background: none; + border: none; + color: white; + font-size: 1.2rem; + cursor: pointer; + } + + /* Landing Page Mobile */ + .phone-mockup { + display: none; + } + + .landing-content { + padding: 2rem 1rem; + } + + .feature-item { + font-size: 1rem; + margin-bottom: 1rem; + } + + /* Register Page Mobile */ + .register-card { + padding: 1.5rem; + margin: 1rem; + border-radius: 10px; + } + + /* Chat Messages Mobile */ + .message-content { + max-width: 85%; + } + + .message-bubble { + font-size: 0.9rem; + padding: 10px 12px; + } + + /* Modal Adjustments */ + .modal-dialog { + margin: 1rem; + max-width: calc(100% - 2rem); + } + + /* Input Adjustments */ + .form-control { + font-size: 16px; /* Prevents zoom on iOS */ + -webkit-appearance: none; + border-radius: 0.375rem; + } + + /* Chat input specific adjustments */ + .chat-input { + padding: 0.5rem 1rem; + border-top: 1px solid #e9ecef; + position: sticky; + bottom: 0; + background: #f0f2f5; + } + + .chat-input .form-control { + border-radius: 25px; + padding: 0.75rem 1rem; + border: 1px solid #ddd; + } + + .chat-input .btn { + border-radius: 50%; + width: 45px; + height: 45px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + } + + /* Avatar Size Adjustments */ + .avatar { + width: 36px; + height: 36px; + font-size: 0.9rem; + } + + .avatar-large { + width: 70px; + height: 70px; + font-size: 1.8rem; + } +} + +/* Extra Small Devices */ +@media (max-width: 576px) { + .sidebar { + width: 100%; + max-width: 100%; + } + + .register-card { + margin: 0.5rem; + padding: 1rem; + } + + .landing-content { + padding: 1.5rem 0.5rem; + } + + .btn { + padding: 0.75rem 1.5rem; + } + + .modal-dialog { + margin: 0.5rem; + max-width: calc(100% - 1rem); + } +} + +/* Landscape Orientation on Small Devices */ +@media (max-height: 500px) and (max-width: 768px) { + .sidebar-header { + padding: 0.5rem 1rem; + } + + .search-box { + padding: 0.5rem 1rem; + } + + .new-chat-btn { + padding: 0.5rem 1rem; + } + + .conversation-item { + padding: 0.5rem 1rem; + } + + .mobile-header { + padding: 0.5rem 1rem; + } +} + +/* Utility Classes */ +.text-online { + color: #25d366 !important; +} + +.text-offline { + color: #999 !important; +} + +.border-success { + border-color: #25d366 !important; +} + +.btn-success { + background-color: #25d366; + border-color: #25d366; +} + +.btn-success:hover { + background-color: #128c7e; + border-color: #128c7e; +} + +.btn-outline-success { + color: #25d366; + border-color: #25d366; +} + +.btn-outline-success:hover { + background-color: #25d366; + border-color: #25d366; +} + +/* Custom Scrollbars */ +.conversations-list::-webkit-scrollbar, +.chat-messages::-webkit-scrollbar { + width: 6px; +} + +.conversations-list::-webkit-scrollbar-track, +.chat-messages::-webkit-scrollbar-track { + background: #f1f1f1; +} + +.conversations-list::-webkit-scrollbar-thumb, +.chat-messages::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.conversations-list::-webkit-scrollbar-thumb:hover, +.chat-messages::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* Touch-friendly styles */ +@media (hover: none) and (pointer: coarse) { + .conversation-item:hover { + background-color: transparent; + } + + .conversation-item:active { + background-color: #f8f9fa; + } + + .btn:hover { + transform: none; + } + + .btn:active { + transform: scale(0.98); + } +} + +/* Animation Classes */ +.fade-in { + animation: fadeIn 0.3s ease-in; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.slide-in { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { transform: translateX(-20px); opacity: 0; } + to { transform: translateX(0); opacity: 1; } +} + +/* Loading Spinner */ +.loading-spinner { + display: inline-block; + width: 20px; + height: 20px; + border: 2px solid #f3f3f3; + border-top: 2px solid #25d366; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} diff --git a/instance/whatsapp_clone.db b/instance/whatsapp_clone.db new file mode 100644 index 0000000000000000000000000000000000000000..2d60106d3607567e289c02b8c5013c42194d4973 Binary files /dev/null and b/instance/whatsapp_clone.db differ diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..85f00c45b2fa2f691eef638ae470fcee713eb2a0 --- /dev/null +++ b/main.py @@ -0,0 +1,4 @@ +from app import app + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/models.py b/models.py new file mode 100644 index 0000000000000000000000000000000000000000..34bc95fa513bcdb06a79df44277ff76e04a34315 --- /dev/null +++ b/models.py @@ -0,0 +1,85 @@ +from datetime import datetime +from app import db +import uuid +import secrets +import string + +class User(db.Model): + __tablename__ = 'users' + + user_id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + unique_id = db.Column(db.String(8), unique=True, nullable=False) + name = db.Column(db.String(100), nullable=False) + email = db.Column(db.String(120), unique=True, nullable=False) + online = db.Column(db.Boolean, default=False) + last_seen = db.Column(db.DateTime, default=datetime.utcnow) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __init__(self, **kwargs): + super(User, self).__init__(**kwargs) + if not self.unique_id: + self.unique_id = self.generate_unique_id() + + @staticmethod + def generate_unique_id(): + """Generate a unique 8-character ID""" + while True: + unique_id = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8)) + if not User.query.filter_by(unique_id=unique_id).first(): + return unique_id + +class Conversation(db.Model): + __tablename__ = 'conversations' + + id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + name = db.Column(db.String(200), nullable=False) + type = db.Column(db.String(20), nullable=False, default='private') # 'private' or 'group' + created_by = db.Column(db.String(36), db.ForeignKey('users.user_id'), nullable=False) + created_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __init__(self, **kwargs): + super(Conversation, self).__init__(**kwargs) + +class ConversationParticipant(db.Model): + __tablename__ = 'conversation_participants' + + id = db.Column(db.Integer, primary_key=True) + conversation_id = db.Column(db.String(36), db.ForeignKey('conversations.id'), nullable=False) + user_id = db.Column(db.String(36), db.ForeignKey('users.user_id'), nullable=False) + joined_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __init__(self, **kwargs): + super(ConversationParticipant, self).__init__(**kwargs) + +class Message(db.Model): + __tablename__ = 'messages' + + id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + conversation_id = db.Column(db.String(36), db.ForeignKey('conversations.id'), nullable=False) + sender_id = db.Column(db.String(36), db.ForeignKey('users.user_id'), nullable=False) + content = db.Column(db.Text) + message_type = db.Column(db.String(20), default='text') # 'text', 'file', 'audio', 'image' + file_path = db.Column(db.String(500)) + file_name = db.Column(db.String(255)) + file_size = db.Column(db.Integer) + file_type = db.Column(db.String(100)) + audio_duration = db.Column(db.Float) # Duration in seconds for audio files + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + + # Relationships + sender = db.relationship('User', backref='sent_messages') + conversation = db.relationship('Conversation', backref='messages') + + def __init__(self, **kwargs): + super(Message, self).__init__(**kwargs) + +class MessageSeen(db.Model): + __tablename__ = 'message_seen' + + id = db.Column(db.Integer, primary_key=True) + message_id = db.Column(db.String(36), db.ForeignKey('messages.id'), nullable=False) + user_id = db.Column(db.String(36), db.ForeignKey('users.user_id'), nullable=False) + seen_at = db.Column(db.DateTime, default=datetime.utcnow) + + def __init__(self, **kwargs): + super(MessageSeen, self).__init__(**kwargs) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..7f5f80abd6d1afc81283c2edc5ed58d112c890ae --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,18 @@ +[project] +name = "repl-nix-workspace" +version = "0.1.0" +description = "Add your description here" +requires-python = ">=3.11" +dependencies = [ + "email-validator>=2.2.0", + "flask-dance>=7.1.0", + "flask>=3.1.1", + "flask-sqlalchemy>=3.1.1", + "gunicorn>=23.0.0", + "psycopg2-binary>=2.9.10", + "flask-login>=0.6.3", + "oauthlib>=3.3.1", + "pyjwt>=2.10.1", + "sqlalchemy>=2.0.41", + "werkzeug>=3.1.3", +] diff --git a/replit.md b/replit.md new file mode 100644 index 0000000000000000000000000000000000000000..d5df836980b02538851a4c1f24bc204059392e40 --- /dev/null +++ b/replit.md @@ -0,0 +1,99 @@ +# WhatsApp Clone + +## Overview + +This is a Flask-based WhatsApp clone application that provides real-time messaging functionality with features like group chats, file sharing, voice message recording, and online/offline status tracking. The application uses a traditional server-side architecture with SQLAlchemy for database operations and real-time updates through client-side polling. + +## User Preferences + +Preferred communication style: Simple, everyday language. + +## System Architecture + +The application follows a traditional web application architecture with: + +- **Frontend**: Server-side rendered HTML templates with vanilla JavaScript for interactivity +- **Backend**: Flask web framework with SQLAlchemy ORM for database operations +- **Database**: SQLite (default) with PostgreSQL support through environment configuration +- **File Storage**: Local filesystem storage for uploaded files +- **Real-time Updates**: Client-side polling mechanism for message updates + +## Key Components + +### Backend Components + +1. **Flask Application** (`app.py`) + - Main application configuration and setup + - Database initialization with SQLAlchemy + - File upload configuration (100MB max, multiple file types supported) + - Session management with secret key configuration + +2. **Database Models** (`models.py`) + - User model with unique ID generation system + - Conversation model supporting both private and group chats + - ConversationParticipant model for managing chat memberships + - Message model for storing chat messages (incomplete in current codebase) + - MessageSeen model for tracking message read status (referenced but not fully implemented) + +3. **Routes** (`routes.py`) + - RESTful API endpoints for user registration, messaging, and file operations + - File upload/download handling with MIME type detection + - Authentication and session management + - Conversation and message management endpoints + +### Frontend Components + +1. **Templates** (`templates/`) + - Landing page for user onboarding + - Registration form for new user signup + - Main chat interface with sidebar and message area + - Settings page for user account management + +2. **Static Assets** (`static/`) + - CSS styling with responsive design and mobile support + - JavaScript modules for chat functionality, file handling, and audio recording + - Modular architecture with separate files for different features + +## Data Flow + +1. **User Registration**: Users register with name and email, receiving an auto-generated 8-character unique ID +2. **Authentication**: Session-based authentication using Flask sessions +3. **Conversation Creation**: Users can create private or group conversations (3-10 members) +4. **Message Exchange**: Real-time messaging through client-side polling mechanism +5. **File Sharing**: Upload and download files with automatic MIME type detection and icon assignment +6. **Voice Messages**: Web Audio API integration for recording and playback (in development) + +## External Dependencies + +### Backend Dependencies +- **Flask**: Web framework for application structure +- **SQLAlchemy**: ORM for database operations with declarative base +- **Werkzeug**: WSGI utilities and security features +- **ProxyFix**: Middleware for handling proxy headers + +### Frontend Dependencies +- **Bootstrap 5.3.0**: CSS framework for responsive UI design +- **Font Awesome 6.0.0**: Icon library for UI elements +- **Web Audio API**: Browser API for voice recording functionality + +### Database Support +- **SQLite**: Default database for development and simple deployments +- **PostgreSQL**: Production database support through DATABASE_URL environment variable + +## Deployment Strategy + +The application is designed for flexible deployment with: + +1. **Environment Configuration**: Database and session secrets configurable via environment variables +2. **Proxy Support**: ProxyFix middleware for deployment behind reverse proxies +3. **File Storage**: Local filesystem storage with configurable upload directory +4. **Database Migration**: Automatic table creation on application startup +5. **Debug Mode**: Configurable debug mode for development vs. production + +### Key Configuration Options +- `DATABASE_URL`: Database connection string (defaults to SQLite) +- `SESSION_SECRET`: Secret key for session management +- `UPLOAD_FOLDER`: Directory for file uploads +- `MAX_CONTENT_LENGTH`: Maximum file upload size (100MB default) + +The architecture supports both development environments (with SQLite and debug mode) and production deployments (with PostgreSQL and proxy configuration). The modular JavaScript architecture and responsive CSS design ensure compatibility across different devices and screen sizes. \ No newline at end of file diff --git a/routes.py b/routes.py new file mode 100644 index 0000000000000000000000000000000000000000..7bd29a2bc98528d3f90d5c1c85f9c130590caf5d --- /dev/null +++ b/routes.py @@ -0,0 +1,762 @@ +import os +import mimetypes +from datetime import datetime +from flask import render_template, request, jsonify, session, redirect, url_for, send_file, send_from_directory +from werkzeug.utils import secure_filename +from sqlalchemy import or_, and_, desc +from sqlalchemy.orm import joinedload +from app import app, db +from models import User, Conversation, ConversationParticipant, Message, MessageSeen + +def allowed_file(filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS'] + +def get_file_icon(file_type): + """Return appropriate Font Awesome icon for file type""" + if file_type.startswith('image/'): + return 'fas fa-image' + elif file_type.startswith('audio/'): + return 'fas fa-music' + elif file_type.startswith('video/'): + return 'fas fa-video' + elif 'pdf' in file_type: + return 'fas fa-file-pdf' + elif any(word in file_type for word in ['word', 'document']): + return 'fas fa-file-word' + elif any(word in file_type for word in ['excel', 'sheet']): + return 'fas fa-file-excel' + elif any(word in file_type for word in ['powerpoint', 'presentation']): + return 'fas fa-file-powerpoint' + elif 'zip' in file_type or 'archive' in file_type: + return 'fas fa-file-archive' + elif 'apk' in file_type: + return 'fab fa-android' + else: + return 'fas fa-file' + +def format_file_size(size_bytes): + """Convert bytes to human readable format""" + if size_bytes == 0: + return "0B" + size_names = ["B", "KB", "MB", "GB", "TB"] + i = 0 + while size_bytes >= 1024.0 and i < len(size_names) - 1: + size_bytes /= 1024.0 + i += 1 + return f"{size_bytes:.1f}{size_names[i]}" + +@app.route('/') +def landing(): + if 'user_id' in session: + return redirect(url_for('chat')) + return render_template('landing.html') + +@app.route('/register') +def register(): + if 'user_id' in session: + return redirect(url_for('chat')) + return render_template('register.html') + +@app.route('/chat') +def chat(): + if 'user_id' not in session: + return redirect(url_for('landing')) + + user = User.query.get(session['user_id']) + if not user: + session.clear() + return redirect(url_for('landing')) + + # Update user online status + user.online = True + user.last_seen = datetime.utcnow() + db.session.commit() + + return render_template('chat.html', user=user) + +@app.route('/settings') +def settings(): + if 'user_id' not in session: + return redirect(url_for('landing')) + + user = User.query.get(session['user_id']) + if not user: + session.clear() + return redirect(url_for('landing')) + + return render_template('settings.html', user=user) + +@app.route('/logout') +def logout(): + if 'user_id' in session: + user = User.query.get(session['user_id']) + if user: + user.online = False + user.last_seen = datetime.utcnow() + db.session.commit() + + session.clear() + return redirect(url_for('landing')) + +# API Routes + +@app.route('/api/register', methods=['POST']) +def api_register(): + try: + data = request.get_json() + name = data.get('name', '').strip() + email = data.get('email', '').strip().lower() + + if not name or not email: + return jsonify({'success': False, 'message': 'Name and email are required'}) + + # Check if email already exists + if User.query.filter_by(email=email).first(): + return jsonify({'success': False, 'message': 'Email already registered'}) + + # Create new user + user = User(name=name, email=email) + db.session.add(user) + db.session.commit() + + session['user_id'] = user.user_id + + return jsonify({'success': True, 'message': 'Account created successfully'}) + + except Exception as e: + app.logger.error(f"Registration error: {e}") + return jsonify({'success': False, 'message': 'Registration failed'}) + +@app.route('/api/conversations') +def api_conversations(): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + user_id = session['user_id'] + + try: + # Get conversations where user is a participant + conversations = db.session.query(Conversation).join( + ConversationParticipant, + Conversation.id == ConversationParticipant.conversation_id + ).filter( + ConversationParticipant.user_id == user_id + ).all() + + result = [] + for conv in conversations: + # Get other participants + participants = db.session.query(User).join( + ConversationParticipant, + User.user_id == ConversationParticipant.user_id + ).filter( + ConversationParticipant.conversation_id == conv.id + ).all() + + # Get last message + last_message = None + last_msg = Message.query.filter_by(conversation_id=conv.id).order_by(Message.timestamp.desc()).first() + if last_msg: + sender = User.query.get(last_msg.sender_id) + last_message = { + 'content': last_msg.content or (f"📎 {last_msg.file_name}" if last_msg.message_type == 'file' else + "🎵 Voice message" if last_msg.message_type == 'audio' else + "📷 Image" if last_msg.message_type == 'image' else last_msg.content), + 'timestamp': last_msg.timestamp.isoformat(), + 'sender_name': sender.name if sender else 'Unknown' + } + + # For private chats, use the other participant's info + if conv.type == 'private': + other_participant = next((p for p in participants if p.user_id != user_id), None) + if other_participant: + conv_name = other_participant.name + online = other_participant.online + else: + conv_name = "Unknown User" + online = False + else: + conv_name = conv.name + online = any(p.online for p in participants if p.user_id != user_id) + + result.append({ + 'id': conv.id, + 'name': conv_name, + 'type': conv.type, + 'online': online, + 'participants': [{'id': p.user_id, 'name': p.name, 'online': p.online} for p in participants], + 'last_message': last_message + }) + + # Sort by last message timestamp + result.sort(key=lambda x: x['last_message']['timestamp'] if x['last_message'] else '1970-01-01T00:00:00', reverse=True) + + return jsonify({'success': True, 'conversations': result}) + + except Exception as e: + app.logger.error(f"Error loading conversations: {e}") + return jsonify({'success': False, 'message': 'Failed to load conversations'}) + +@app.route('/api/messages/') +def api_messages(conversation_id): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + user_id = session['user_id'] + + try: + # Verify user is participant in this conversation + participant = ConversationParticipant.query.filter_by( + conversation_id=conversation_id, + user_id=user_id + ).first() + + if not participant: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Get messages + messages = Message.query.filter_by( + conversation_id=conversation_id + ).order_by(Message.timestamp).all() + + result = [] + for msg in messages: + message_data = { + 'id': msg.id, + 'sender_id': msg.sender_id, + 'sender_name': (lambda user: user.name if user else 'Unknown')(User.query.get(msg.sender_id)), + 'content': msg.content, + 'message_type': msg.message_type, + 'timestamp': msg.timestamp.isoformat(), + 'seen_by': [seen.user_id for seen in MessageSeen.query.filter_by(message_id=msg.id).all()] + } + + # Add file information if it's a file message + if msg.message_type in ['file', 'audio', 'image']: + message_data.update({ + 'file_name': msg.file_name, + 'file_size': msg.file_size, + 'file_type': msg.file_type, + 'file_size_formatted': format_file_size(msg.file_size) if msg.file_size else '0B', + 'file_icon': get_file_icon(msg.file_type or ''), + 'audio_duration': msg.audio_duration + }) + + result.append(message_data) + + return jsonify({'success': True, 'messages': result}) + + except Exception as e: + app.logger.error(f"Error loading messages: {e}") + return jsonify({'success': False, 'message': 'Failed to load messages'}) + +@app.route('/api/send_message', methods=['POST']) +def api_send_message(): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + user_id = session['user_id'] + + try: + data = request.get_json() + conversation_id = data.get('conversation_id') + content = data.get('content', '').strip() + + if not conversation_id or not content: + return jsonify({'success': False, 'message': 'Conversation ID and content are required'}) + + # Verify user is participant + participant = ConversationParticipant.query.filter_by( + conversation_id=conversation_id, + user_id=user_id + ).first() + + if not participant: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Create message + message = Message( + conversation_id=conversation_id, + sender_id=user_id, + content=content, + message_type='text' + ) + + db.session.add(message) + db.session.commit() + + # Get sender info + sender = User.query.get(user_id) + + # Return complete message data for instant display + message_data = { + 'id': message.id, + 'sender_id': message.sender_id, + 'sender_name': sender.name if sender else 'Unknown', + 'content': message.content, + 'message_type': message.message_type, + 'timestamp': message.timestamp.isoformat(), + 'seen_by': [] # Initially empty + } + + return jsonify({'success': True, 'message': message_data}) + + except Exception as e: + app.logger.error(f"Error sending message: {e}") + return jsonify({'success': False, 'message': 'Failed to send message'}) + + +@app.route('/api/upload_file', methods=['POST']) +def api_upload_file(): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + user_id = session['user_id'] + + try: + if 'file' not in request.files: + return jsonify({'success': False, 'message': 'No file uploaded'}) + + file = request.files['file'] + conversation_id = request.form.get('conversation_id') + + if not conversation_id: + return jsonify({'success': False, 'message': 'Conversation ID is required'}) + + if file.filename == '': + return jsonify({'success': False, 'message': 'No file selected'}) + + # Verify user is participant + participant = ConversationParticipant.query.filter_by( + conversation_id=conversation_id, + user_id=user_id + ).first() + + if not participant: + return jsonify({'success': False, 'message': 'Access denied'}) + + if file and file.filename and allowed_file(file.filename): + filename = secure_filename(file.filename) or 'unnamed_file' + + # Create unique filename to avoid conflicts + base_name, ext = os.path.splitext(filename) + unique_filename = f"{base_name}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}{ext}" + + file_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename) + file.save(file_path) + + # Get file info + file_size = os.path.getsize(file_path) + file_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream' + + # Determine message type based on file type + message_type = 'file' + if file_type.startswith('image/'): + message_type = 'image' + elif file_type.startswith('audio/'): + message_type = 'audio' + + # Create message + message = Message( + conversation_id=conversation_id, + sender_id=user_id, + content=f"📎 {filename}", + message_type=message_type, + file_path=unique_filename, + file_name=filename, + file_size=file_size, + file_type=file_type + ) + + db.session.add(message) + db.session.commit() + + return jsonify({'success': True, 'message': 'File uploaded successfully'}) + else: + return jsonify({'success': False, 'message': 'File type not allowed'}) + + except Exception as e: + app.logger.error(f"Error uploading file: {e}") + return jsonify({'success': False, 'message': 'Failed to upload file'}) + +@app.route('/api/upload_audio', methods=['POST']) +def api_upload_audio(): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + user_id = session['user_id'] + + try: + if 'audio' not in request.files: + return jsonify({'success': False, 'message': 'No audio file uploaded'}) + + audio_file = request.files['audio'] + conversation_id = request.form.get('conversation_id') + duration = float(request.form.get('duration', 0)) + + if not conversation_id: + return jsonify({'success': False, 'message': 'Conversation ID is required'}) + + # Verify user is participant + participant = ConversationParticipant.query.filter_by( + conversation_id=conversation_id, + user_id=user_id + ).first() + + if not participant: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Create unique filename + filename = f"voice_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.webm" + file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + audio_file.save(file_path) + + # Get file info + file_size = os.path.getsize(file_path) + + # Create message + message = Message( + conversation_id=conversation_id, + sender_id=user_id, + content="🎵 Voice message", + message_type='audio', + file_path=filename, + file_name="Voice message", + file_size=file_size, + file_type='audio/webm', + audio_duration=duration + ) + + db.session.add(message) + db.session.commit() + + return jsonify({'success': True, 'message': 'Voice message sent'}) + + except Exception as e: + app.logger.error(f"Error uploading audio: {e}") + return jsonify({'success': False, 'message': 'Failed to send voice message'}) + +@app.route('/api/download/') +def api_download(message_id): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + user_id = session['user_id'] + + try: + # Get message + message = Message.query.get(message_id) + if not message or not message.file_path: + return jsonify({'success': False, 'message': 'File not found'}), 404 + + # Verify user has access to this conversation + participant = ConversationParticipant.query.filter_by( + conversation_id=message.conversation_id, + user_id=user_id + ).first() + + if not participant: + return jsonify({'success': False, 'message': 'Access denied'}), 403 + + file_path = os.path.join(app.config['UPLOAD_FOLDER'], message.file_path) + + if not os.path.exists(file_path): + return jsonify({'success': False, 'message': 'File not found on server'}), 404 + + return send_file( + file_path, + as_attachment=True, + download_name=message.file_name, + mimetype=message.file_type + ) + + except Exception as e: + app.logger.error(f"Error downloading file: {e}") + return jsonify({'success': False, 'message': 'Download failed'}), 500 + +@app.route('/api/find_user', methods=['POST']) +def api_find_user(): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + try: + data = request.get_json() + unique_id = data.get('unique_id', '').strip().upper() + + if not unique_id: + return jsonify({'success': False, 'message': 'User ID is required'}) + + user = User.query.filter_by(unique_id=unique_id).first() + if not user: + return jsonify({'success': False, 'message': 'User not found'}) + + if user.user_id == session['user_id']: + return jsonify({'success': False, 'message': 'Cannot start chat with yourself'}) + + return jsonify({ + 'success': True, + 'user': { + 'user_id': user.user_id, + 'name': user.name, + 'unique_id': user.unique_id, + 'online': user.online + } + }) + + except Exception as e: + app.logger.error(f"Error finding user: {e}") + return jsonify({'success': False, 'message': 'Search failed'}) + +@app.route('/api/start_private_chat', methods=['POST']) +def api_start_private_chat(): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + user_id = session['user_id'] + + try: + data = request.get_json() + other_user_id = data.get('user_id') + + if not other_user_id: + return jsonify({'success': False, 'message': 'User ID is required'}) + + if other_user_id == user_id: + return jsonify({'success': False, 'message': 'Cannot start chat with yourself'}) + + # Check if conversation already exists + existing_conv = db.session.query(Conversation).join( + ConversationParticipant, Conversation.id == ConversationParticipant.conversation_id + ).filter( + Conversation.type == 'private', + ConversationParticipant.user_id.in_([user_id, other_user_id]) + ).group_by(Conversation.id).having( + db.func.count(ConversationParticipant.user_id) == 2 + ).first() + + if existing_conv: + # Check if both users are participants + participants = ConversationParticipant.query.filter_by(conversation_id=existing_conv.id).all() + participant_ids = [p.user_id for p in participants] + if set(participant_ids) == {user_id, other_user_id}: + return jsonify({'success': True, 'conversation_id': existing_conv.id}) + + # Create new conversation + other_user = User.query.get(other_user_id) + if not other_user: + return jsonify({'success': False, 'message': 'User not found'}) + + conversation = Conversation( + name=f"Private chat with {other_user.name}", + type='private', + created_by=user_id + ) + + db.session.add(conversation) + db.session.flush() # Get the ID + + # Add participants + participant1 = ConversationParticipant(conversation_id=conversation.id, user_id=user_id) + participant2 = ConversationParticipant(conversation_id=conversation.id, user_id=other_user_id) + + db.session.add(participant1) + db.session.add(participant2) + db.session.commit() + + return jsonify({'success': True, 'conversation_id': conversation.id}) + + except Exception as e: + app.logger.error(f"Error starting private chat: {e}") + return jsonify({'success': False, 'message': 'Failed to start chat'}) + +@app.route('/api/create_group', methods=['POST']) +def api_create_group(): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + user_id = session['user_id'] + + try: + data = request.get_json() + group_name = data.get('name', '').strip() + member_ids = data.get('members', []) + + if not group_name: + return jsonify({'success': False, 'message': 'Group name is required'}) + + if len(member_ids) < 1 or len(member_ids) > 9: + return jsonify({'success': False, 'message': 'Group must have 2-10 members (including you)'}) + + # Verify all members exist + members = User.query.filter(User.unique_id.in_(member_ids)).all() + if len(members) != len(member_ids): + return jsonify({'success': False, 'message': 'Some members not found'}) + + # Create conversation + conversation = Conversation( + name=group_name, + type='group', + created_by=user_id + ) + + db.session.add(conversation) + db.session.flush() # Get the ID + + # Add creator as participant + creator_participant = ConversationParticipant(conversation_id=conversation.id, user_id=user_id) + db.session.add(creator_participant) + + # Add other participants + for member in members: + if member.user_id != user_id: # Don't add creator twice + participant = ConversationParticipant(conversation_id=conversation.id, user_id=member.user_id) + db.session.add(participant) + + db.session.commit() + + return jsonify({'success': True, 'conversation_id': conversation.id}) + + except Exception as e: + app.logger.error(f"Error creating group: {e}") + return jsonify({'success': False, 'message': 'Failed to create group'}) + +@app.route('/api/update_status', methods=['POST']) +def api_update_status(): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + try: + user = User.query.get(session['user_id']) + if user: + data = request.get_json() + user.online = data.get('online', True) + user.last_seen = datetime.utcnow() + db.session.commit() + + return jsonify({'success': True}) + + except Exception as e: + app.logger.error(f"Error updating status: {e}") + return jsonify({'success': False, 'message': 'Failed to update status'}) + +# Double Blue Tick System - Mark messages as seen +@app.route('/api/mark_seen', methods=['POST']) +def api_mark_seen(): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + user_id = session['user_id'] + + try: + data = request.get_json() + message_ids = data.get('message_ids', []) + + if not message_ids: + return jsonify({'success': False, 'message': 'Message IDs required'}) + + # Mark messages as seen for this user + for message_id in message_ids: + # Check if already seen + existing_seen = MessageSeen.query.filter_by( + message_id=message_id, + user_id=user_id + ).first() + + if not existing_seen: + message_seen = MessageSeen( + message_id=message_id, + user_id=user_id + ) + db.session.add(message_seen) + + db.session.commit() + return jsonify({'success': True}) + + except Exception as e: + app.logger.error(f"Error marking messages as seen: {e}") + return jsonify({'success': False, 'message': 'Failed to mark messages as seen'}) + +# Get message seen status for double blue tick display +@app.route('/api/message_status/') +def api_message_status(message_id): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}) + + try: + # Get message + message = Message.query.get(message_id) + if not message: + return jsonify({'success': False, 'message': 'Message not found'}) + + # Check if user has access to this conversation + participant = ConversationParticipant.query.filter_by( + conversation_id=message.conversation_id, + user_id=session['user_id'] + ).first() + + if not participant: + return jsonify({'success': False, 'message': 'Access denied'}) + + # Get all participants in the conversation except sender + conversation_participants = ConversationParticipant.query.filter( + ConversationParticipant.conversation_id == message.conversation_id, + ConversationParticipant.user_id != message.sender_id + ).all() + + # Count how many have seen the message + seen_count = MessageSeen.query.filter_by(message_id=message_id).count() + total_recipients = len(conversation_participants) + + # Determine status: sent (1 tick), delivered (2 gray ticks), seen (2 blue ticks) + if seen_count == 0: + status = 'delivered' # 2 gray ticks + elif seen_count == total_recipients: + status = 'seen' # 2 blue ticks + else: + status = 'partially_seen' # 2 blue ticks + + return jsonify({ + 'success': True, + 'status': status, + 'seen_count': seen_count, + 'total_recipients': total_recipients + }) + + except Exception as e: + app.logger.error(f"Error getting message status: {e}") + return jsonify({'success': False, 'message': 'Failed to get message status'}) + +# Image preview endpoint for WhatsApp-like image viewing +@app.route('/api/image/') +def api_view_image(message_id): + if 'user_id' not in session: + return jsonify({'success': False, 'message': 'Not authenticated'}), 401 + + user_id = session['user_id'] + + try: + # Get message + message = Message.query.get(message_id) + if not message or message.message_type != 'image': + return jsonify({'success': False, 'message': 'Image not found'}), 404 + + # Verify user has access to this conversation + participant = ConversationParticipant.query.filter_by( + conversation_id=message.conversation_id, + user_id=user_id + ).first() + + if not participant: + return jsonify({'success': False, 'message': 'Access denied'}), 403 + + # Return image file + file_path = os.path.join(app.config['UPLOAD_FOLDER'], message.file_path) + if os.path.exists(file_path): + return send_file(file_path) + else: + return jsonify({'success': False, 'message': 'File not found on server'}), 404 + + except Exception as e: + app.logger.error(f"Error viewing image: {e}") + return jsonify({'success': False, 'message': 'Failed to load image'}), 500 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000000000000000000000000000000000000..f2fea60ecc98702d27fbeddbf340c891838546f4 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,946 @@ +/* General Styles */ +* { + box-sizing: border-box; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background-color: #f0f2f5; + margin: 0; + padding: 0; + height: 100vh; + overflow: hidden; + transition: all 0.2s ease; +} + +/* Smooth transitions for UI elements */ +.conversation-item, +.message-item, +.message-bubble, +.btn, +input, +textarea { + transition: all 0.2s ease; +} + +/* Touch optimization for mobile */ +.conversation-item, +.btn, +button { + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +/* Ensure touch works properly always */ +* { + touch-action: manipulation; +} + +body { + touch-action: manipulation; +} + +/* Image preview specific touch handling */ +.image-preview-modal { + touch-action: none; /* Only for the modal itself */ +} + +.image-preview-modal img { + touch-action: auto; /* Allow zooming/panning on images */ +} + +.h-100 { + height: 100vh !important; +} + +/* Chat Page Layout */ +.chat-page { + height: 100vh; + overflow: hidden; +} + +.chat-page .container-fluid { + height: 100vh; + max-height: 100vh; +} + +.chat-page .row { + height: 100vh; + max-height: 100vh; +} + +.sidebar { + background: white; + border-right: 1px solid #e9ecef; + height: 100vh; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +/* Fix for all screen sizes 769px and above */ +@media (min-width: 769px) { + .chat-page .sidebar { + display: flex !important; + position: relative !important; + height: 100vh !important; + max-height: 100vh !important; + width: 33.33333% !important; + flex: 0 0 auto !important; + } + + .chat-page .chat-area { + display: block !important; + height: 100vh !important; + max-height: 100vh !important; + width: 66.66667% !important; + flex: 0 0 auto !important; + } + + .chat-page .col-md-4 { + width: 33.33333% !important; + flex: 0 0 33.33333% !important; + } + + .chat-page .col-md-8 { + width: 66.66667% !important; + flex: 0 0 66.66667% !important; + } +} + +/* Image Preview Modal (WhatsApp-like) */ +.image-preview-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.image-preview-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.9); + display: flex; + align-items: center; + justify-content: center; +} + +.image-preview-container { + position: relative; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; + align-items: center; +} + +.image-preview-close { + position: absolute; + top: -50px; + right: 0; + background: none; + border: none; + color: white; + font-size: 24px; + cursor: pointer; + z-index: 10000; + padding: 10px; +} + +.image-preview-close:hover { + color: #ccc; +} + +.image-preview-image { + max-width: 100%; + max-height: 80vh; + object-fit: contain; + border-radius: 8px; +} + +.image-preview-actions { + margin-top: 20px; + display: flex; + gap: 10px; +} + +/* Message Status Icons (Double Blue Tick) */ +.message-status { + margin-left: 5px; + font-size: 12px; +} + +.message-status.sent { + color: #95a5a6; +} + +.message-status.delivered { + color: #95a5a6; +} + +.message-status.seen { + color: #25d366; /* WhatsApp blue tick color */ +} + +/* Image Message Styling */ +.image-message { + position: relative; + cursor: pointer; + border-radius: 12px; + overflow: hidden; + max-width: 280px; +} + +.message-image { + width: 100%; + height: auto; + display: block; + border-radius: 12px; +} + +.image-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-size: 24px; + transition: all 0.3s ease; + opacity: 0; +} + +.image-message:hover .image-overlay { + background: rgba(0, 0, 0, 0.3); + opacity: 1; +} + +/* Also fix the md-4 and md-8 at exactly 768px */ +@media (min-width: 768px) and (max-width: 768px) { + .chat-page .sidebar { + display: flex !important; + position: relative !important; + height: 100vh !important; + } + + .chat-page .chat-area { + display: block !important; + height: 100vh !important; + } +} + +.sidebar-header { + background: #25d366; + color: white; + padding: 1rem; + flex-shrink: 0; +} + +.search-box { + padding: 0.5rem; + flex-shrink: 0; + background: #f0f2f5; +} + +.new-chat-btn { + padding: 0.5rem; + flex-shrink: 0; + background: #f0f2f5; +} + +.conversations-list { + flex: 1; + overflow-y: auto; +} + +/* Mobile First Approach */ +html { + font-size: 16px; +} + +@media (max-width: 768px) { + html { + font-size: 14px; + } +} + +/* Landing Page */ +.landing-page { + background: linear-gradient(135deg, #25d366 0%, #128c7e 100%); + color: white; + overflow: auto; + min-height: 100vh; +} + +.landing-content { + max-width: 500px; + padding: 1rem; +} + +.logo { + width: 120px; + height: 120px; + filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2)); +} + +.logo-small { + width: 60px; + height: 60px; +} + +.feature-item { + justify-content: flex-start; + text-align: left; + font-size: 1.1rem; +} + +.phone-mockup { + width: 300px; + height: 600px; + background: #000; + border-radius: 30px; + padding: 20px; + box-shadow: 0 20px 40px rgba(0,0,0,0.3); +} + +.phone-screen { + width: 100%; + height: 100%; + background: white; + border-radius: 20px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.chat-header { + background: #25d366; + color: white; + padding: 15px; + flex-shrink: 0; +} + +.chat-messages { + flex: 1; + padding: 20px; + overflow-y: auto; +} + +.message { + margin-bottom: 15px; +} + +.message.sent { + text-align: right; +} + +.message-bubble { + display: inline-block; + padding: 8px 12px; + border-radius: 18px; + max-width: 80%; + word-wrap: break-word; +} + +.message.received .message-bubble { + background: #f1f1f1; + color: #333; +} + +.message.sent .message-bubble { + background: #dcf8c6; + color: #333; +} + +/* Register Page */ +.register-page { + background: linear-gradient(135deg, #25d366 0%, #128c7e 100%); + overflow: auto; + min-height: 100vh; + padding: 1rem; +} + +.register-card { + background: white; + padding: 2rem; + border-radius: 15px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); + width: 100%; + max-width: 500px; + margin: 0 auto; +} + +.conversation-item { + padding: 0.75rem 1rem; + border-bottom: 1px solid #e9ecef; + cursor: pointer; + transition: all 0.15s ease; + position: relative; +} + +.conversation-item:hover { + background-color: #f8f9fa; + transform: translateX(2px); +} + +.conversation-item.active { + background-color: #e3f2fd; + border-left: 4px solid #25d366; + transform: translateX(4px); +} + +.conversation-item:active { + transform: scale(0.98); +} + +.chat-area { + background: #e5ddd5; + height: 100vh; + position: relative; + width: 100%; +} + +.welcome-screen { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: url('data:image/svg+xml,') repeat; +} + +.chat-container { + height: 100%; + display: flex; + flex-direction: column; +} + +.chat-header { + background: #25d366; + color: white; + padding: 1rem; + flex-shrink: 0; + border-bottom: 1px solid #128c7e; +} + +.chat-messages { + flex: 1; + padding: 1rem; + overflow-y: auto; + background: #e5ddd5; + background-image: url('data:image/svg+xml,'); +} + +.chat-input { + background: #f0f2f5; + padding: 0.75rem 1rem; + flex-shrink: 0; +} + +.chat-input-toolbar { + display: flex; + align-items: center; +} + +.message-item { + margin-bottom: 1rem; + display: flex; + align-items: flex-end; +} + +.message-item.sent { + justify-content: flex-end; +} + +.message-content { + max-width: 70%; + position: relative; +} + +.message-bubble { + padding: 8px 12px; + border-radius: 18px; + word-wrap: break-word; + position: relative; +} + +.message-item.received .message-bubble { + background: white; + color: #333; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); +} + +.message-item.sent .message-bubble { + background: #dcf8c6; + color: #333; + box-shadow: 0 1px 2px rgba(0,0,0,0.1); +} + +.message-time { + font-size: 0.75rem; + color: #666; + margin-top: 4px; + text-align: right; +} + +.message-status { + margin-left: 4px; +} + +.message-status.sent { + color: #999; +} + +.message-status.delivered { + color: #4fc3f7; +} + +.message-status.seen { + color: #25d366; +} + +/* File Message Styles */ +.file-message { + background: white; + border: 1px solid #e9ecef; + border-radius: 12px; + padding: 12px; + max-width: 300px; + cursor: pointer; + transition: background-color 0.2s; +} + +.file-message:hover { + background-color: #f8f9fa; +} + +.file-message.sent { + background: #dcf8c6; + border-color: #c5d9a5; +} + +.file-info { + display: flex; + align-items: center; +} + +.file-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 8px; + margin-right: 12px; + font-size: 1.2rem; +} + +.file-icon.pdf { + background: #ff6b6b; + color: white; +} + +.file-icon.image { + background: #4ecdc4; + color: white; +} + +.file-icon.audio { + background: #45b7d1; + color: white; +} + +.file-icon.video { + background: #f9ca24; + color: white; +} + +.file-icon.document { + background: #6c5ce7; + color: white; +} + +.file-icon.archive { + background: #fd79a8; + color: white; +} + +.file-icon.default { + background: #74b9ff; + color: white; +} + +.file-details { + flex: 1; + min-width: 0; +} + +.file-name { + font-weight: 500; + margin-bottom: 2px; + word-wrap: break-word; + font-size: 0.9rem; +} + +.file-size { + color: #666; + font-size: 0.8rem; +} + +/* Audio Message Styles */ +.audio-message { + background: white; + border: 1px solid #e9ecef; + border-radius: 18px; + padding: 8px 12px; + max-width: 250px; + display: flex; + align-items: center; +} + +.audio-message.sent { + background: #dcf8c6; + border-color: #c5d9a5; +} + +.audio-controls { + display: flex; + align-items: center; +} + +.audio-play-btn { + background: #25d366; + border: none; + border-radius: 50%; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + color: white; + cursor: pointer; + margin-right: 8px; +} + +.audio-play-btn:hover { + background: #128c7e; +} + +.audio-duration { + font-size: 0.8rem; + color: #666; +} + +.audio-waveform { + width: 80px; + height: 20px; + background: url('data:image/svg+xml,') no-repeat center; + margin: 0 8px; +} + +/* Image Message Styles */ +.image-message { + border-radius: 12px; + overflow: hidden; + max-width: 200px; + cursor: pointer; +} + +.image-message img { + width: 100%; + height: auto; + display: block; +} + +/* Audio Recording UI */ +.audio-recording-ui { + margin-bottom: 0.75rem; +} + +.recording-indicator { + animation: pulse 1.5s ease-in-out infinite alternate; +} + +@keyframes pulse { + from { opacity: 0.5; } + to { opacity: 1; } +} + +/* Avatar Styles */ +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + font-size: 1rem; +} + +.avatar-large { + width: 80px; + height: 80px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: bold; + font-size: 2rem; +} + +.online-indicator { + width: 12px; + height: 12px; + background: #25d366; + border: 2px solid white; + border-radius: 50%; + position: absolute; + bottom: 0; + right: 0; +} + +/* Settings Page */ +.settings-page { + background: #f0f2f5; + min-height: 100vh; + padding: 2rem 0; +} + +.settings-section { + margin-bottom: 2rem; +} + +.setting-item { + padding: 1rem 0; + border-bottom: 1px solid #e9ecef; +} + +.setting-item:last-child { + border-bottom: none; +} + +/* File Upload Progress */ +.upload-progress { + background: rgba(255, 255, 255, 0.9); + border-radius: 12px; + padding: 12px; + margin-bottom: 8px; + border: 1px solid #e9ecef; +} + +.progress-bar-custom { + height: 4px; + background: #25d366; + border-radius: 2px; + transition: width 0.3s ease; +} + +/* Responsive Design */ +@media (max-width: 768px) { + body { + overflow-y: auto; + overflow-x: hidden; + } + + .h-100 { + min-height: 100vh; + height: auto; + } + + /* Mobile Chat Layout */ + .chat-page .row { + height: 100vh; + } + + .sidebar { + position: fixed; + top: 0; + left: -100%; + width: 85%; + max-width: 320px; + z-index: 1050; + transition: left 0.3s ease; + box-shadow: 0 0 20px rgba(0,0,0,0.3); + } + + .sidebar.show { + left: 0; + } + + .sidebar-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + z-index: 1040; + display: none; + } + + .sidebar-overlay.show { + display: block; + } + + .chat-area { + width: 100%; + position: relative; + } + + .mobile-header { + display: flex; + align-items: center; + justify-content: space-between; + background: #25d366; + color: white; + padding: 0.75rem 1rem; + position: sticky; + top: 0; + z-index: 100; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + } + + .mobile-menu-btn { + background: none; + border: none; + color: white; + font-size: 1.2rem; + cursor: pointer; + } + + /* Landing Page Mobile */ + .phone-mockup { + display: none; + } + + .landing-content { + padding: 2rem 1rem; + } + + .feature-item { + font-size: 1rem; + margin-bottom: 1rem; + } + + /* Register Page Mobile */ + .register-card { + padding: 1.5rem; + margin: 1rem; + border-radius: 10px; + } + + /* Chat Messages Mobile */ + .message-content { + max-width: 85%; + } + + .file-message { + max-width: 250px; + } + + .audio-message { + max-width: 200px; + } + + .image-message { + max-width: 180px; + } + + .chat-input-toolbar { + flex-wrap: wrap; + gap: 0.5rem; + } +} + +/* Tablet adjustments */ +@media (min-width: 769px) and (max-width: 991px) { + .file-message { + max-width: 350px; + } + + .message-content { + max-width: 75%; + } +} + +/* Large screen adjustments */ +@media (min-width: 1200px) { + .file-message { + max-width: 400px; + } + + .message-content { + max-width: 65%; + } +} + +/* Additional file type icons */ +.file-icon.apk { + background: #a4c639; + color: white; +} + +.file-icon.exe { + background: #0078d4; + color: white; +} + +.file-icon.zip { + background: #fd79a8; + color: white; +} + +/* Hover effects for interactive elements */ +.message-item:hover .file-message { + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} + +.audio-play-btn:active { + transform: scale(0.95); +} + +/* Loading states */ +.loading-spinner { + display: inline-block; + width: 16px; + height: 16px; + border: 2px solid #ccc; + border-radius: 50%; + border-top-color: #25d366; + animation: spin 1s ease-in-out infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Error states */ +.error-message { + color: #dc3545; + font-size: 0.8rem; + margin-top: 4px; +} + +/* Success states */ +.success-message { + color: #28a745; + font-size: 0.8rem; + margin-top: 4px; +} diff --git a/static/images/logo.svg b/static/images/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..f212f775b9b3253796e2eee239fcc5a6caec2617 --- /dev/null +++ b/static/images/logo.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/static/js/audio.js b/static/js/audio.js new file mode 100644 index 0000000000000000000000000000000000000000..14dc2e4dacc8731712852ed3a3aca402d1c0d6d3 --- /dev/null +++ b/static/js/audio.js @@ -0,0 +1,414 @@ +// Audio recording and playback functionality using Web Audio API + +let mediaRecorder = null; +let audioChunks = []; +let recordingStream = null; +let recordingStartTime = null; +let recordingTimer = null; +let isRecording = false; + +// Initialize audio recording functionality +document.addEventListener('DOMContentLoaded', () => { + if (document.body.classList.contains('chat-page')) { + initializeAudioRecording(); + } +}); + +async function initializeAudioRecording() { + try { + // Check if getUserMedia is supported + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + console.warn('getUserMedia not supported'); + return; + } + + console.log('Audio recording initialized'); + } catch (error) { + console.error('Error initializing audio recording:', error); + } +} + +async function toggleAudioRecording() { + if (isRecording) { + stopAudioRecording(); + } else { + startAudioRecording(); + } +} + +async function startAudioRecording() { + if (!window.currentConversation) { + MainJS.showError('Please select a conversation first'); + return; + } + + try { + // Request microphone permission + recordingStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + sampleRate: 44100 + } + }); + + // Create MediaRecorder + const options = { + mimeType: 'audio/webm;codecs=opus' + }; + + // Fallback to other formats if webm is not supported + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options.mimeType = 'audio/webm'; + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options.mimeType = 'audio/mp4'; + if (!MediaRecorder.isTypeSupported(options.mimeType)) { + options.mimeType = 'audio/wav'; + } + } + } + + mediaRecorder = new MediaRecorder(recordingStream, options); + audioChunks = []; + + // Set up event handlers + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunks.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + handleRecordingStop(); + }; + + mediaRecorder.onerror = (event) => { + console.error('MediaRecorder error:', event.error); + MainJS.showError('Recording failed: ' + event.error.message); + resetRecordingUI(); + }; + + // Start recording + mediaRecorder.start(100); // Collect data every 100ms + isRecording = true; + recordingStartTime = Date.now(); + + // Update UI + updateRecordingUI(true); + + // Start timer + startRecordingTimer(); + + console.log('Audio recording started'); + + } catch (error) { + console.error('Error starting audio recording:', error); + + if (error.name === 'NotAllowedError') { + MainJS.showError('Microphone access denied. Please allow microphone access to record voice messages.'); + } else if (error.name === 'NotFoundError') { + MainJS.showError('No microphone found. Please connect a microphone and try again.'); + } else { + MainJS.showError('Failed to start recording: ' + error.message); + } + + resetRecordingUI(); + } +} + +function stopAudioRecording() { + if (!isRecording || !mediaRecorder) { + return; + } + + try { + mediaRecorder.stop(); + isRecording = false; + + // Stop all tracks + if (recordingStream) { + recordingStream.getTracks().forEach(track => track.stop()); + recordingStream = null; + } + + // Stop timer + if (recordingTimer) { + clearInterval(recordingTimer); + recordingTimer = null; + } + + console.log('Audio recording stopped'); + + } catch (error) { + console.error('Error stopping audio recording:', error); + MainJS.showError('Failed to stop recording'); + resetRecordingUI(); + } +} + +function cancelAudioRecording() { + if (isRecording && mediaRecorder) { + mediaRecorder.stop(); + isRecording = false; + + // Stop all tracks + if (recordingStream) { + recordingStream.getTracks().forEach(track => track.stop()); + recordingStream = null; + } + + // Stop timer + if (recordingTimer) { + clearInterval(recordingTimer); + recordingTimer = null; + } + + // Clear chunks + audioChunks = []; + + // Reset UI + resetRecordingUI(); + + console.log('Audio recording cancelled'); + } +} + +async function handleRecordingStop() { + if (audioChunks.length === 0) { + console.warn('No audio data recorded'); + resetRecordingUI(); + return; + } + + try { + // Create blob from chunks + const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); + const duration = (Date.now() - recordingStartTime) / 1000; // Duration in seconds + + // Validate minimum duration + if (duration < 0.5) { + MainJS.showError('Recording too short. Please record for at least 0.5 seconds.'); + resetRecordingUI(); + return; + } + + // Validate maximum duration (5 minutes) + if (duration > 300) { + MainJS.showError('Recording too long. Maximum duration is 5 minutes.'); + resetRecordingUI(); + return; + } + + console.log(`Audio recorded: ${duration.toFixed(2)} seconds, size: ${audioBlob.size} bytes`); + + // Upload audio + await uploadAudioMessage(audioBlob, duration); + + } catch (error) { + console.error('Error handling recording stop:', error); + MainJS.showError('Failed to process recording'); + } finally { + resetRecordingUI(); + } +} + +async function uploadAudioMessage(audioBlob, duration) { + if (!window.currentConversation) { + MainJS.showError('No conversation selected'); + return; + } + + try { + // Create form data + const formData = new FormData(); + formData.append('audio', audioBlob, 'voice_message.webm'); + formData.append('conversation_id', window.currentConversation); + formData.append('duration', duration.toString()); + + // Show uploading indicator + MainJS.showSuccess('Sending voice message...'); + + // Upload audio + const response = await fetch('/api/upload_audio', { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (result.success) { + MainJS.showSuccess('Voice message sent!'); + + // Reload messages and conversations + await loadMessages(window.currentConversation); + await loadConversations(); + } else { + MainJS.showError('Failed to send voice message: ' + result.message); + } + + } catch (error) { + console.error('Error uploading audio:', error); + MainJS.showError('Failed to send voice message'); + } +} + +function startRecordingTimer() { + recordingTimer = setInterval(() => { + if (!isRecording) return; + + const elapsed = (Date.now() - recordingStartTime) / 1000; + const minutes = Math.floor(elapsed / 60); + const seconds = Math.floor(elapsed % 60); + + const timeString = `${minutes}:${seconds.toString().padStart(2, '0')}`; + const timeElement = document.getElementById('recordingTime'); + if (timeElement) { + timeElement.textContent = timeString; + } + + // Auto-stop at 5 minutes + if (elapsed >= 300) { + stopAudioRecording(); + } + }, 100); +} + +function updateRecordingUI(recording) { + const audioButton = document.getElementById('audioButton'); + const audioRecording = document.getElementById('audioRecording'); + const messageForm = document.getElementById('messageForm'); + + if (!audioButton || !audioRecording || !messageForm) return; + + if (recording) { + audioButton.innerHTML = ''; + audioButton.classList.add('btn-danger'); + audioButton.classList.remove('btn-outline-success'); + audioRecording.style.display = 'block'; + messageForm.style.display = 'none'; + } else { + resetRecordingUI(); + } +} + +function resetRecordingUI() { + const audioButton = document.getElementById('audioButton'); + const audioRecording = document.getElementById('audioRecording'); + const messageForm = document.getElementById('messageForm'); + const recordingTime = document.getElementById('recordingTime'); + + if (audioButton) { + audioButton.innerHTML = ''; + audioButton.classList.remove('btn-danger'); + audioButton.classList.add('btn-outline-success'); + } + + if (audioRecording) { + audioRecording.style.display = 'none'; + } + + if (messageForm) { + messageForm.style.display = 'flex'; + } + + if (recordingTime) { + recordingTime.textContent = '00:00'; + } +} + +// Audio playback functionality +const audioElements = new Map(); + +async function playAudioMessage(messageId) { + try { + // Stop any currently playing audio + audioElements.forEach(audio => { + if (!audio.paused) { + audio.pause(); + audio.currentTime = 0; + } + }); + + // Get or create audio element for this message + let audio = audioElements.get(messageId); + + if (!audio) { + // Fetch audio data + const response = await fetch(`/api/download/${messageId}`); + if (!response.ok) { + throw new Error('Failed to load audio'); + } + + const blob = await response.blob(); + const audioUrl = URL.createObjectURL(blob); + + // Create audio element + audio = new Audio(audioUrl); + audioElements.set(messageId, audio); + + // Update play button when audio ends + audio.addEventListener('ended', () => { + updateAudioButton(messageId, false); + URL.revokeObjectURL(audioUrl); + audioElements.delete(messageId); + }); + + // Handle errors + audio.addEventListener('error', (e) => { + console.error('Audio playback error:', e); + MainJS.showError('Failed to play audio message'); + updateAudioButton(messageId, false); + URL.revokeObjectURL(audioUrl); + audioElements.delete(messageId); + }); + } + + // Toggle play/pause + if (audio.paused) { + updateAudioButton(messageId, true); + await audio.play(); + } else { + audio.pause(); + updateAudioButton(messageId, false); + } + + } catch (error) { + console.error('Error playing audio message:', error); + MainJS.showError('Failed to play audio message'); + } +} + +function updateAudioButton(messageId, playing) { + const button = document.querySelector(`[onclick*="${messageId}"]`); + if (button) { + const icon = button.querySelector('i'); + if (icon) { + if (playing) { + icon.className = 'fas fa-pause'; + button.style.background = '#128c7e'; + } else { + icon.className = 'fas fa-play'; + button.style.background = '#25d366'; + } + } + } +} + +// Cleanup audio elements on page unload +window.addEventListener('beforeunload', () => { + audioElements.forEach(audio => { + if (!audio.paused) { + audio.pause(); + } + // URLs will be automatically revoked when the page unloads + }); + audioElements.clear(); +}); + +// Export functions for global access +window.AudioJS = { + toggleAudioRecording, + cancelAudioRecording, + stopAudioRecording, + playAudioMessage +}; diff --git a/static/js/chat.js b/static/js/chat.js new file mode 100644 index 0000000000000000000000000000000000000000..5d1bb0f5155ca6df390b80d30aa0b72212d0c0de --- /dev/null +++ b/static/js/chat.js @@ -0,0 +1,1016 @@ +// Chat functionality +let conversations = []; +let messages = {}; +let pollingInterval; + +// Mobile sidebar functionality +function toggleMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + + if (sidebar && overlay) { + sidebar.classList.toggle('show'); + overlay.classList.toggle('show'); + + // Prevent body scroll when sidebar is open + if (sidebar.classList.contains('show')) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + } +} + +// Close mobile sidebar when clicking outside +function closeMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + + if (sidebar && overlay) { + sidebar.classList.remove('show'); + overlay.classList.remove('show'); + document.body.style.overflow = ''; + } +} + +// Initialize chat functionality +document.addEventListener('DOMContentLoaded', () => { + if (!document.body.classList.contains('chat-page')) { + return; + } + + initializeChat(); +}); + +async function initializeChat() { + try { + console.log('Initializing chat...'); + + // Clear any existing data + conversations = []; + messages = {}; + window.currentConversation = null; + + // Load fresh data + await loadConversations(); + startPolling(); + setupEventListeners(); + + console.log('Chat initialized successfully'); + } catch (error) { + console.error('Failed to initialize chat:', error); + MainJS.showError('Failed to initialize chat'); + } +} + +function setupEventListeners() { + // Message form + const messageForm = document.getElementById('messageForm'); + if (messageForm) { + messageForm.addEventListener('submit', handleSendMessage); + } + + // Private chat form + const privateChatForm = document.getElementById('privateChatForm'); + if (privateChatForm) { + privateChatForm.addEventListener('submit', handleStartPrivateChat); + } + + // Group chat form + const groupChatForm = document.getElementById('groupChatForm'); + if (groupChatForm) { + groupChatForm.addEventListener('submit', handleCreateGroup); + } +} + +async function loadConversations() { + try { + const response = await MainJS.apiRequest('/api/conversations'); + + if (response.success) { + conversations = response.conversations || []; + renderConversations(); + } else { + console.warn('Failed to load conversations:', response.message); + // Show empty state instead of error for unauthenticated users + conversations = []; + renderConversations(); + } + } catch (error) { + console.error('Failed to load conversations:', error); + conversations = []; + renderConversations(); + } +} + +function renderConversations() { + const conversationsList = document.getElementById('conversationsList'); + if (!conversationsList) { + console.error('Conversations list element not found'); + return; + } + + console.log('Rendering conversations:', conversations); + + // FORCE CLEAR the conversations list first + conversationsList.innerHTML = ''; + + if (conversations.length === 0) { + conversationsList.innerHTML = ` +
+ +

No conversations yet

+ Start a new chat to begin messaging +
+ `; + console.log('No conversations to display'); + return; + } + + conversationsList.innerHTML = conversations.map(conv => { + const lastMessage = conv.last_message; + const isActive = window.currentConversation === conv.id; + + return ` +
+
+
+ ${conv.type === 'group' ? '' : conv.name[0].toUpperCase()} + ${conv.type === 'private' && conv.online ? '
' : ''} +
+
+
+
${MainJS.escapeHtml(conv.name)}
+ ${lastMessage ? `${MainJS.formatTime(lastMessage.timestamp)}` : ''} +
+ ${lastMessage ? ` +
+ ${conv.type === 'group' ? `${MainJS.escapeHtml(lastMessage.sender_name)}: ` : ''} + ${MainJS.escapeHtml(lastMessage.content)} +
+ ` : '
No messages yet
'} + ${conv.type === 'private' && !conv.online ? '
offline
' : ''} +
+
+
+ `; + }).join(''); +} + +async function selectConversation(conversationId) { + try { + console.log('Selecting conversation:', conversationId); + + // Validate that conversation exists + const conversation = conversations.find(c => c.id === conversationId); + if (!conversation) { + console.error('Conversation not found:', conversationId); + MainJS.showError('Conversation not found. Please refresh and try again.'); + return; + } + + window.currentConversation = conversationId; + + // Update UI + document.querySelectorAll('.conversation-item').forEach(item => { + item.classList.remove('active'); + }); + + // Find and activate the clicked conversation + const clickedItem = document.querySelector(`[onclick*="${conversationId}"]`); + if (clickedItem) { + clickedItem.classList.add('active'); + } + + // Show chat container + const welcomeScreen = document.getElementById('welcomeScreen'); + const chatContainer = document.getElementById('chatContainer'); + + if (welcomeScreen) welcomeScreen.style.display = 'none'; + if (chatContainer) { + chatContainer.style.display = 'flex'; + console.log('Chat container displayed'); + } else { + console.error('Chat container not found'); + } + + // Close mobile sidebar when conversation is selected + if (window.innerWidth < 768) { + closeMobileSidebar(); + } + + // Update chat header first + updateChatHeader(conversationId); + + // Load conversation details with local storage for instant loading + console.log('Loading messages for conversation:', conversationId); + await loadMessagesWithLocalStorage(conversationId); + + // Mark messages as seen + markMessagesAsSeen(conversationId); + + console.log('Conversation selected successfully'); + } catch (error) { + console.error('Error selecting conversation:', error); + MainJS.showError('Failed to load conversation'); + } +} + +async function loadMessages(conversationId) { + try { + console.log('Loading messages for conversation ID:', conversationId); + const response = await MainJS.apiRequest(`/api/messages/${conversationId}`); + console.log('Messages API response:', response); + + if (response.success) { + messages[conversationId] = response.messages || []; + console.log('Messages loaded:', response.messages.length); + + // Save messages to local storage + saveMessagesToLocalStorage(conversationId, messages[conversationId]); + + renderMessages(conversationId); + } else { + console.error('API error:', response.message); + // Even if API fails, show the chat interface with empty state + messages[conversationId] = []; + renderMessages(conversationId); + MainJS.showError('Failed to load messages: ' + response.message); + } + } catch (error) { + console.error('Failed to load messages:', error); + // Show empty chat interface even on error + messages[conversationId] = []; + renderMessages(conversationId); + MainJS.showError('Connection error while loading messages'); + } +} + +function renderMessages(conversationId) { + console.log('Rendering messages for conversation:', conversationId); + const chatMessages = document.getElementById('chatMessages'); + console.log('Chat messages element:', chatMessages); + console.log('Messages data:', messages[conversationId]); + + if (!chatMessages) { + console.error('Chat messages element not found!'); + return; + } + + if (!messages[conversationId]) { + console.error('No messages found for conversation:', conversationId); + return; + } + + const conversationMessages = messages[conversationId]; + console.log('Number of messages to render:', conversationMessages.length); + + if (conversationMessages.length === 0) { + console.log('No messages, showing empty state'); + chatMessages.innerHTML = ` +
+ +

No messages yet

+ Send the first message to start the conversation +
+ `; + return; + } + + chatMessages.innerHTML = conversationMessages.map(msg => { + const isCurrentUser = msg.sender_id === getCurrentUserId(); + const messageClass = isCurrentUser ? 'sent' : 'received'; + + // Render different message types + if (msg.message_type === 'image') { + return renderImageMessage(msg, messageClass); + } else if (msg.message_type === 'file') { + return renderFileMessage(msg, messageClass); + } else if (msg.message_type === 'audio') { + return renderAudioMessage(msg, messageClass); + } else { + return renderTextMessage(msg, messageClass); + } + }).join(''); + + // Scroll to bottom + chatMessages.scrollTop = chatMessages.scrollHeight; + + // Mark messages as seen when viewing conversation + setTimeout(() => markVisibleMessagesAsSeen(), 500); +} + +function renderTextMessage(msg, messageClass) { + const isCurrentUser = messageClass === 'sent'; + return ` +
+
+ ${!isCurrentUser && getConversationType(window.currentConversation) === 'group' ? + `
${MainJS.escapeHtml(msg.sender_name)}
` : ''} +
+ ${MainJS.escapeHtml(msg.content)} +
+
+ ${MainJS.formatMessageTime(msg.timestamp)} + ${isCurrentUser ? getMessageStatusIcon(msg) : ''} +
+
+
+ `; +} + +function renderImageMessage(msg, messageClass) { + const isCurrentUser = messageClass === 'sent'; + return ` +
+
+ ${!isCurrentUser && getConversationType(window.currentConversation) === 'group' ? + `
${MainJS.escapeHtml(msg.sender_name)}
` : ''} +
+ ${MainJS.escapeHtml(msg.file_name || 'Image')} +
+ +
+
+
+ ${MainJS.formatMessageTime(msg.timestamp)} + ${isCurrentUser ? getMessageStatusIcon(msg) : ''} +
+
+
+ `; +} + +function renderFileMessage(msg, messageClass) { + const isCurrentUser = messageClass === 'sent'; + const iconClass = MainJS.getFileIconClass(msg.file_type || ''); + const iconColor = MainJS.getFileIconColor(msg.file_type || ''); + + return ` +
+
+ ${!isCurrentUser && getConversationType(window.currentConversation) === 'group' ? + `
${MainJS.escapeHtml(msg.sender_name)}
` : ''} +
+
+
+ +
+
+
${MainJS.escapeHtml(msg.file_name || 'Unknown File')}
+
${msg.file_size_formatted || '0B'}
+
+
+
+
+ ${MainJS.formatMessageTime(msg.timestamp)} + ${isCurrentUser ? getMessageStatusIcon(msg) : ''} +
+
+
+ `; +} + +function renderAudioMessage(msg, messageClass) { + const isCurrentUser = messageClass === 'sent'; + const duration = msg.audio_duration ? Math.floor(msg.audio_duration) : 0; + const minutes = Math.floor(duration / 60); + const seconds = duration % 60; + const durationText = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + return ` +
+
+ ${!isCurrentUser && getConversationType(window.currentConversation) === 'group' ? + `
${MainJS.escapeHtml(msg.sender_name)}
` : ''} +
+
+ +
+
${durationText}
+
+
+
+ ${MainJS.formatMessageTime(msg.timestamp)} + ${isCurrentUser ? getMessageStatusIcon(msg) : ''} +
+
+
+ `; +} + +function getMessageStatusIcon(message) { + // FIXED: Proper blue tick logic + // Blue ticks ONLY when recipient is ONLINE AND has actually seen the message + // Gray ticks when delivered but not seen OR when recipient is offline + + const seenCount = message.seen_by ? message.seen_by.length : 0; + const currentUserId = document.querySelector('[data-user-id]')?.getAttribute('data-user-id'); + + // For group chats, check if any non-sender has seen it while online + const conversation = conversations.find(c => c.id === window.currentConversation); + if (!conversation) { + // Default to single gray tick if can't find conversation + return ''; + } + + // Check if ANY recipient is currently online AND has seen the message + const hasOnlineRecipientSeen = conversation.participants.some(participant => { + return participant.id !== currentUserId && // Not the sender + participant.online === true && // Currently online + message.seen_by && message.seen_by.includes(participant.id); // Has seen the message + }); + + if (hasOnlineRecipientSeen) { + // Blue double tick: seen by online recipient + return ''; + } else if (seenCount > 0) { + // Gray double tick: seen but recipient was offline or is offline now + return ''; + } else { + // Single gray tick: delivered but not seen + return ''; + } +} + +function updateChatHeader(conversationId) { + const chatHeader = document.getElementById('chatHeader'); + const conversation = conversations.find(c => c.id === conversationId); + + if (!chatHeader || !conversation) return; + + chatHeader.innerHTML = ` +
+
+ ${conversation.type === 'group' ? '' : conversation.name[0].toUpperCase()} + ${conversation.type === 'private' && conversation.online ? '
' : ''} +
+
+
${MainJS.escapeHtml(conversation.name)}
+ + ${conversation.type === 'group' + ? `${conversation.participants.length} members` + : conversation.online ? 'online' : 'offline' + } + +
+
+ `; +} + +async function handleSendMessage(event) { + event.preventDefault(); + + const messageInput = document.getElementById('messageInput'); + const content = messageInput.value.trim(); + + if (!content || !window.currentConversation) { + return; + } + + try { + const response = await MainJS.apiRequest('/api/send_message', { + method: 'POST', + body: JSON.stringify({ + conversation_id: window.currentConversation, + content: content + }) + }); + + if (response.success) { + messageInput.value = ''; + + // Add message to local state for instant display (zero delay like your reference code) + if (!messages[window.currentConversation]) { + messages[window.currentConversation] = []; + } + messages[window.currentConversation].push(response.message); + + // Save messages to local storage + saveMessagesToLocalStorage(window.currentConversation, messages[window.currentConversation]); + + // Render messages instantly for fast response - no setTimeout delays + requestAnimationFrame(() => { + renderMessages(window.currentConversation); + }); + + // Update conversations list + await loadConversations(); + } else { + MainJS.showError('Failed to send message: ' + response.message); + } + } catch (error) { + console.error('Error sending message:', error); + MainJS.showError('Failed to send message'); + } +} + +// File download function +async function downloadFile(messageId) { + try { + window.open(`/api/download/${messageId}`, '_blank'); + } catch (error) { + console.error('Error downloading file:', error); + MainJS.showError('Failed to download file'); + } +} + +// Audio playback function +async function playAudioMessage(messageId) { + try { + const response = await fetch(`/api/download/${messageId}`); + if (response.ok) { + const blob = await response.blob(); + const audioUrl = URL.createObjectURL(blob); + const audio = new Audio(audioUrl); + + audio.play().catch(error => { + console.error('Error playing audio:', error); + MainJS.showError('Failed to play audio'); + }); + + // Clean up URL when audio ends + audio.addEventListener('ended', () => { + URL.revokeObjectURL(audioUrl); + }); + } + } catch (error) { + console.error('Error playing audio:', error); + MainJS.showError('Failed to play audio'); + } +} + +// Helper functions +function getCurrentUserId() { + return window.currentUserId; +} + +function getConversationType(conversationId) { + const conversation = conversations.find(c => c.id === conversationId); + return conversation ? conversation.type : 'private'; +} + +async function markMessagesAsSeen(conversationId) { + try { + // Get all message IDs from current conversation + const conversationMessages = messages[conversationId] || []; + const messageIds = conversationMessages.map(msg => msg.id); + + if (messageIds.length > 0) { + await MainJS.apiRequest('/api/mark_seen', { + method: 'POST', + body: JSON.stringify({ + message_ids: messageIds + }) + }); + } + } catch (error) { + console.error('Failed to mark messages as seen:', error); + } +} + +function startPolling() { + // Poll for new messages every 1 second for instant response like your original code + pollingInterval = setInterval(async () => { + try { + // Reload conversations to get latest messages + await loadConversations(); + + // If a conversation is selected, reload its messages + if (window.currentConversation) { + await loadMessages(window.currentConversation); + markMessagesAsSeen(window.currentConversation); + } + } catch (error) { + console.error('Polling error:', error); + } + }, 1000); +} + +// New chat functions +function startPrivateChat() { + const newChatModal = bootstrap.Modal.getInstance(document.getElementById('newChatModal')); + const privateChatModal = new bootstrap.Modal(document.getElementById('privateChatModal')); + + newChatModal.hide(); + privateChatModal.show(); +} + +function startGroupChat() { + const newChatModal = bootstrap.Modal.getInstance(document.getElementById('newChatModal')); + const groupChatModal = new bootstrap.Modal(document.getElementById('groupChatModal')); + + newChatModal.hide(); + groupChatModal.show(); +} + +async function findUser() { + const userIdInput = document.getElementById('userIdInput'); + const uniqueId = userIdInput.value.trim().toUpperCase(); + + if (!uniqueId) { + MainJS.showError('Please enter a user ID'); + return; + } + + try { + const response = await MainJS.apiRequest('/api/find_user', { + method: 'POST', + body: JSON.stringify({ unique_id: uniqueId }) + }); + + const userPreview = document.getElementById('userPreview'); + const startChatBtn = document.getElementById('startChatBtn'); + + if (response.success) { + userPreview.innerHTML = ` +
+
${response.user.name[0].toUpperCase()}
+
+
${MainJS.escapeHtml(response.user.name)}
+ ${response.user.unique_id} +
+
+ `; + userPreview.style.display = 'block'; + startChatBtn.style.display = 'block'; + startChatBtn.dataset.userId = response.user.user_id; + } else { + userPreview.innerHTML = `
${response.message}
`; + userPreview.style.display = 'block'; + startChatBtn.style.display = 'none'; + } + } catch (error) { + console.error('Error finding user:', error); + MainJS.showError('Failed to find user'); + } +} + +async function handleStartPrivateChat(event) { + event.preventDefault(); + + const startChatBtn = document.getElementById('startChatBtn'); + const userId = startChatBtn.dataset.userId; + + if (!userId) { + MainJS.showError('Please find a user first'); + return; + } + + try { + const response = await MainJS.apiRequest('/api/start_private_chat', { + method: 'POST', + body: JSON.stringify({ user_id: userId }) + }); + + if (response.success) { + const privateChatModal = bootstrap.Modal.getInstance(document.getElementById('privateChatModal')); + privateChatModal.hide(); + + // Refresh conversations and select the new one + await loadConversations(); + await selectConversation(response.conversation_id); + } else { + MainJS.showError('Failed to start chat: ' + response.message); + } + } catch (error) { + console.error('Error starting private chat:', error); + MainJS.showError('Failed to start chat'); + } +} + +async function handleCreateGroup(event) { + event.preventDefault(); + + const groupName = document.getElementById('groupNameInput').value.trim(); + const memberInputs = document.querySelectorAll('.member-input'); + const members = Array.from(memberInputs) + .map(input => input.value.trim().toUpperCase()) + .filter(value => value); + + if (!groupName) { + MainJS.showError('Please enter a group name'); + return; + } + + if (members.length === 0) { + MainJS.showError('Please add at least one member'); + return; + } + + try { + const response = await MainJS.apiRequest('/api/create_group', { + method: 'POST', + body: JSON.stringify({ + name: groupName, + members: members + }) + }); + + if (response.success) { + const groupChatModal = bootstrap.Modal.getInstance(document.getElementById('groupChatModal')); + groupChatModal.hide(); + + // Reset form + document.getElementById('groupChatForm').reset(); + + // Refresh conversations and select the new one + await loadConversations(); + await selectConversation(response.conversation_id); + } else { + MainJS.showError('Failed to create group: ' + response.message); + } + } catch (error) { + console.error('Error creating group:', error); + MainJS.showError('Failed to create group'); + } +} + +function addMemberField() { + const groupMembers = document.getElementById('groupMembers'); + const memberCount = groupMembers.querySelectorAll('.member-input').length; + + if (memberCount >= 9) { + MainJS.showError('Maximum 9 members allowed'); + return; + } + + const memberField = document.createElement('div'); + memberField.className = 'input-group mb-2'; + memberField.innerHTML = ` + + + `; + + groupMembers.appendChild(memberField); +} + +function removeMemberField(button) { + button.parentElement.remove(); +} + +// Image preview functionality (WhatsApp-like) - SIMPLIFIED TO PREVENT TOUCH ISSUES +function openImagePreview(messageId) { + // Remove any existing image preview first + const existingModal = document.querySelector('.image-preview-modal'); + if (existingModal) { + existingModal.remove(); + } + + const imageUrl = `/api/image/${messageId}`; + + // Create simple modal with minimal interference + const modal = document.createElement('div'); + modal.className = 'image-preview-modal'; + modal.innerHTML = ` +
+
+ + Image Preview + +
+
+ `; + + // Simple modal without any complex touch handling + document.body.appendChild(modal); + + // Add click to close functionality + modal.querySelector('.image-preview-overlay').addEventListener('click', (e) => { + if (e.target === e.currentTarget) { + closeImagePreview(); + } + }); + + // Mark message as seen when viewing image + markMessageAsSeen(messageId); +} + +function closeImagePreview() { + // Simple close function - just remove modal, no complex touch handling + const modal = document.querySelector('.image-preview-modal'); + if (modal) { + modal.remove(); + console.log('Image preview closed'); + } +} + +// Touch event prevention function for modal +function preventTouch(e) { + e.preventDefault(); +} + +// Double blue tick system - Mark messages as seen +async function markMessageAsSeen(messageId) { + try { + await MainJS.apiRequest('/api/mark_seen', { + method: 'POST', + body: JSON.stringify({ message_ids: [messageId] }) + }); + } catch (error) { + console.error('Error marking message as seen:', error); + } +} + +// Mark all visible messages as seen +async function markVisibleMessagesAsSeen() { + if (!window.currentConversation) return; + + const conversationMessages = messages[window.currentConversation] || []; + const messageIds = conversationMessages + .filter(msg => msg.sender_id !== getCurrentUserId()) // Only mark messages from others + .map(msg => msg.id); + + if (messageIds.length > 0) { + try { + await MainJS.apiRequest('/api/mark_seen', { + method: 'POST', + body: JSON.stringify({ message_ids: messageIds }) + }); + } catch (error) { + console.error('Error marking messages as seen:', error); + } + } +} + +// Update message status (for double blue tick display) +async function updateMessageStatuses() { + if (!window.currentConversation) return; + + const conversationMessages = messages[window.currentConversation] || []; + const currentUserId = getCurrentUserId(); + + // Only check status for messages sent by current user + const sentMessages = conversationMessages.filter(msg => msg.sender_id === currentUserId); + + for (const message of sentMessages) { + try { + const response = await MainJS.apiRequest(`/api/message_status/${message.id}`); + if (response.success) { + message.status = response.status; + } + } catch (error) { + console.error('Error updating message status:', error); + } + } +} + +// Local Storage Functions +function saveMessagesToLocalStorage(conversationId, messageList) { + try { + const storageKey = `chat_messages_${conversationId}`; + localStorage.setItem(storageKey, JSON.stringify(messageList)); + console.log(`Saved ${messageList.length} messages to local storage for conversation ${conversationId}`); + } catch (error) { + console.error('Failed to save messages to local storage:', error); + } +} + +function loadMessagesFromLocalStorage(conversationId) { + try { + const storageKey = `chat_messages_${conversationId}`; + const stored = localStorage.getItem(storageKey); + const messages = stored ? JSON.parse(stored) : []; + console.log(`Loaded ${messages.length} messages from local storage for conversation ${conversationId}`); + return messages; + } catch (error) { + console.error('Failed to load messages from local storage:', error); + return []; + } +} + +function clearLocalStorageForConversation(conversationId) { + try { + const storageKey = `chat_messages_${conversationId}`; + localStorage.removeItem(storageKey); + } catch (error) { + console.error('Failed to clear local storage:', error); + } +} + +// Load messages from local storage first, then update from server +async function loadMessagesWithLocalStorage(conversationId) { + try { + console.log('Loading messages with local storage for:', conversationId); + + // First show cached messages for instant loading + const cachedMessages = loadMessagesFromLocalStorage(conversationId); + console.log('Cached messages loaded:', cachedMessages.length); + + if (cachedMessages.length > 0) { + messages[conversationId] = cachedMessages; + renderMessages(conversationId); + console.log('Rendered cached messages'); + } + + // Then load fresh messages from server + console.log('Loading fresh messages from server...'); + await loadMessages(conversationId); + + } catch (error) { + console.error('Error in loadMessagesWithLocalStorage:', error); + // Fallback to regular loading + await loadMessages(conversationId); + } +} + +// Mobile sidebar functions +function toggleMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + + if (sidebar && overlay) { + sidebar.classList.toggle('show'); + overlay.classList.toggle('show'); + } +} + +function closeMobileSidebar() { + const sidebar = document.getElementById('sidebar'); + const overlay = document.getElementById('sidebarOverlay'); + + if (sidebar && overlay) { + sidebar.classList.remove('show'); + overlay.classList.remove('show'); + } +} + +// Chat creation functions +function startPrivateChat() { + // Close new chat modal and open private chat modal + const newChatModal = bootstrap.Modal.getInstance(document.getElementById('newChatModal')); + const privateChatModal = new bootstrap.Modal(document.getElementById('privateChatModal')); + + if (newChatModal) newChatModal.hide(); + privateChatModal.show(); +} + +function startGroupChat() { + // Close new chat modal and open group chat modal + const newChatModal = bootstrap.Modal.getInstance(document.getElementById('newChatModal')); + const groupChatModal = new bootstrap.Modal(document.getElementById('groupChatModal')); + + if (newChatModal) newChatModal.hide(); + groupChatModal.show(); +} + +async function findUser() { + const userIdInput = document.getElementById('userIdInput'); + const userPreview = document.getElementById('userPreview'); + const startChatBtn = document.getElementById('startChatBtn'); + + const userId = userIdInput.value.trim(); + if (!userId) { + MainJS.showError('Please enter a user ID'); + return; + } + + try { + const response = await MainJS.apiRequest('/api/find-user', { + method: 'POST', + body: JSON.stringify({ user_id: userId }) + }); + + if (response.success && response.user) { + userPreview.innerHTML = ` +
+
+ ${response.user.name[0].toUpperCase()} +
+
+
${MainJS.escapeHtml(response.user.name)}
+ ${MainJS.escapeHtml(response.user.email || 'No email')} +
+
+ `; + userPreview.style.display = 'block'; + startChatBtn.style.display = 'block'; + window.foundUser = response.user; + } else { + MainJS.showError('User not found'); + userPreview.style.display = 'none'; + startChatBtn.style.display = 'none'; + } + } catch (error) { + console.error('Find user error:', error); + MainJS.showError('Failed to find user'); + } +} + +// Cleanup on page unload +window.addEventListener('beforeunload', () => { + if (pollingInterval) { + clearInterval(pollingInterval); + } +}); diff --git a/static/js/files.js b/static/js/files.js new file mode 100644 index 0000000000000000000000000000000000000000..107a7b64ad067f9cd564e8d515f809a4217f2643 --- /dev/null +++ b/static/js/files.js @@ -0,0 +1,433 @@ +// File upload and management functionality + +// Initialize file upload functionality +document.addEventListener('DOMContentLoaded', () => { + if (document.body.classList.contains('chat-page')) { + initializeFileUpload(); + } +}); + +function initializeFileUpload() { + const fileInput = document.getElementById('fileInput'); + const imageInput = document.getElementById('imageInput'); + + if (fileInput) { + fileInput.addEventListener('change', handleFileSelect); + } + + if (imageInput) { + imageInput.addEventListener('change', handleFileSelect); + } + + // Handle drag and drop + const chatMessages = document.getElementById('chatMessages'); + if (chatMessages) { + chatMessages.addEventListener('dragover', handleDragOver); + chatMessages.addEventListener('drop', handleFileDrop); + chatMessages.addEventListener('dragenter', handleDragEnter); + chatMessages.addEventListener('dragleave', handleDragLeave); + } + + console.log('File upload initialized'); +} + +function openFileUpload() { + if (!window.currentConversation) { + MainJS.showError('Please select a conversation first'); + return; + } + + const fileInput = document.getElementById('fileInput'); + if (fileInput) { + fileInput.click(); + } +} + +function openImageUpload() { + if (!window.currentConversation) { + MainJS.showError('Please select a conversation first'); + return; + } + + const imageInput = document.getElementById('imageInput'); + if (imageInput) { + imageInput.click(); + } +} + +function handleFileSelect(event) { + const files = Array.from(event.target.files); + if (files.length === 0) return; + + // Reset input value to allow selecting the same file again + event.target.value = ''; + + files.forEach(file => { + uploadFile(file); + }); +} + +function handleDragOver(event) { + event.preventDefault(); + event.dataTransfer.dropEffect = 'copy'; +} + +function handleDragEnter(event) { + event.preventDefault(); + const chatMessages = document.getElementById('chatMessages'); + if (chatMessages) { + chatMessages.classList.add('drag-over'); + } +} + +function handleDragLeave(event) { + event.preventDefault(); + // Only remove class if we're leaving the chat messages area entirely + if (!event.currentTarget.contains(event.relatedTarget)) { + const chatMessages = document.getElementById('chatMessages'); + if (chatMessages) { + chatMessages.classList.remove('drag-over'); + } + } +} + +function handleFileDrop(event) { + event.preventDefault(); + + const chatMessages = document.getElementById('chatMessages'); + if (chatMessages) { + chatMessages.classList.remove('drag-over'); + } + + if (!window.currentConversation) { + MainJS.showError('Please select a conversation first'); + return; + } + + const files = Array.from(event.dataTransfer.files); + files.forEach(file => { + uploadFile(file); + }); +} + +async function uploadFile(file) { + if (!window.currentConversation) { + MainJS.showError('Please select a conversation first'); + return; + } + + // Validate file size (100MB limit) + const maxSize = 100 * 1024 * 1024; // 100MB + if (file.size > maxSize) { + MainJS.showError(`File "${file.name}" is too large. Maximum size is 100MB.`); + return; + } + + // Validate file type + const allowedExtensions = [ + 'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'mp3', 'wav', 'ogg', 'm4a', + 'mp4', 'avi', 'mov', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', + 'zip', 'rar', '7z', 'apk', 'exe', 'dmg', 'deb', 'rpm' + ]; + + const fileExtension = file.name.split('.').pop().toLowerCase(); + if (!allowedExtensions.includes(fileExtension)) { + MainJS.showError(`File type "${fileExtension}" is not allowed.`); + return; + } + + // Create progress UI + const progressId = createProgressUI(file); + + try { + // Create form data + const formData = new FormData(); + formData.append('file', file); + formData.append('conversation_id', window.currentConversation); + + // Upload with progress tracking + const response = await uploadWithProgress(formData, progressId); + + if (response.success) { + updateProgressUI(progressId, 100, 'Upload complete'); + MainJS.showSuccess(`File "${file.name}" uploaded successfully!`); + + // Remove progress UI after a delay + setTimeout(() => { + removeProgressUI(progressId); + }, 2000); + + // Reload messages and conversations + await loadMessages(window.currentConversation); + await loadConversations(); + + } else { + updateProgressUI(progressId, 0, 'Upload failed: ' + response.message); + MainJS.showError('Failed to upload file: ' + response.message); + + // Remove progress UI after delay + setTimeout(() => { + removeProgressUI(progressId); + }, 3000); + } + + } catch (error) { + console.error('Error uploading file:', error); + updateProgressUI(progressId, 0, 'Upload failed'); + MainJS.showError('Failed to upload file: ' + error.message); + + // Remove progress UI after delay + setTimeout(() => { + removeProgressUI(progressId); + }, 3000); + } +} + +function uploadWithProgress(formData, progressId) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + + // Track upload progress + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const percentComplete = (event.loaded / event.total) * 100; + updateProgressUI(progressId, percentComplete, 'Uploading...'); + } + }); + + // Handle completion + xhr.addEventListener('load', () => { + try { + const response = JSON.parse(xhr.responseText); + resolve(response); + } catch (error) { + reject(new Error('Invalid response format')); + } + }); + + // Handle errors + xhr.addEventListener('error', () => { + reject(new Error('Network error during upload')); + }); + + xhr.addEventListener('abort', () => { + reject(new Error('Upload cancelled')); + }); + + // Start upload + xhr.open('POST', '/api/upload_file'); + xhr.send(formData); + }); +} + +function createProgressUI(file) { + const progressId = 'progress_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + const chatMessages = document.getElementById('chatMessages'); + + if (!chatMessages) return progressId; + + const progressElement = document.createElement('div'); + progressElement.id = progressId; + progressElement.className = 'upload-progress'; + + const iconClass = MainJS.getFileIconClass(file.type); + const iconColor = MainJS.getFileIconColor(file.type); + + progressElement.innerHTML = ` +
+
+ +
+
+
${MainJS.escapeHtml(file.name)}
+ ${MainJS.formatFileSize(file.size)} +
+
+
+
+
+
Preparing upload...
+ `; + + chatMessages.appendChild(progressElement); + chatMessages.scrollTop = chatMessages.scrollHeight; + + return progressId; +} + +function updateProgressUI(progressId, percent, status) { + const progressElement = document.getElementById(progressId); + if (!progressElement) return; + + const progressBar = progressElement.querySelector('.progress-bar'); + const statusElement = progressElement.querySelector('.progress-status'); + + if (progressBar) { + progressBar.style.width = percent + '%'; + progressBar.setAttribute('aria-valuenow', percent); + } + + if (statusElement) { + statusElement.textContent = status; + } + + // Change color based on status + if (status.includes('failed') || status.includes('error')) { + if (progressBar) { + progressBar.classList.remove('bg-success'); + progressBar.classList.add('bg-danger'); + } + if (statusElement) { + statusElement.classList.add('text-danger'); + } + } else if (status.includes('complete')) { + if (progressBar) { + progressBar.classList.remove('bg-success'); + progressBar.classList.add('bg-success'); + } + if (statusElement) { + statusElement.classList.add('text-success'); + } + } +} + +function removeProgressUI(progressId) { + const progressElement = document.getElementById(progressId); + if (progressElement) { + progressElement.remove(); + } +} + +// File preview functionality +function previewFile(fileUrl, fileName, fileType) { + if (fileType.startsWith('image/')) { + showImagePreview(fileUrl, fileName); + } else if (fileType.startsWith('text/') || fileType.includes('pdf')) { + window.open(fileUrl, '_blank'); + } else { + // For other file types, just download + downloadFileFromUrl(fileUrl, fileName); + } +} + +function showImagePreview(imageUrl, fileName) { + // Create modal for image preview + const modal = document.createElement('div'); + modal.className = 'modal fade'; + modal.setAttribute('tabindex', '-1'); + + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + // Show modal + const bsModal = new bootstrap.Modal(modal); + bsModal.show(); + + // Remove modal from DOM when hidden + modal.addEventListener('hidden.bs.modal', () => { + modal.remove(); + }); +} + +function downloadFileFromUrl(url, fileName) { + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.style.display = 'none'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +// File type validation +function validateFileType(file) { + const allowedTypes = [ + // Images + 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', + // Audio + 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', + // Video + 'video/mp4', 'video/avi', 'video/quicktime', 'video/webm', + // Documents + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + // Archives + 'application/zip', + 'application/x-rar-compressed', + 'application/x-7z-compressed', + // Text + 'text/plain', + // Applications + 'application/vnd.android.package-archive' + ]; + + return allowedTypes.includes(file.type) || file.type === ''; +} + +// File size formatting (already available in main.js, but keeping for reference) +function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +// Add CSS for drag and drop visual feedback +const dragDropStyles = document.createElement('style'); +dragDropStyles.textContent = ` + .chat-messages.drag-over { + border: 2px dashed #25d366; + background-color: rgba(37, 211, 102, 0.1); + } + + .chat-messages.drag-over::after { + content: "Drop files here to upload"; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(37, 211, 102, 0.9); + color: white; + padding: 1rem 2rem; + border-radius: 8px; + font-weight: bold; + z-index: 1000; + pointer-events: none; + } +`; +document.head.appendChild(dragDropStyles); + +// Export functions for global access +window.FileJS = { + openFileUpload, + openImageUpload, + uploadFile, + previewFile, + validateFileType, + formatFileSize +}; diff --git a/static/js/main.js b/static/js/main.js new file mode 100644 index 0000000000000000000000000000000000000000..0526dce12fa3d1f5b75e2e7dada0033bc398b7ba --- /dev/null +++ b/static/js/main.js @@ -0,0 +1,242 @@ +// Main JavaScript utilities and helpers + +// Global variables +window.currentUser = null; +window.currentConversation = null; + +// Utility functions +function formatTime(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + if (diff < 60000) { // Less than 1 minute + return 'now'; + } else if (diff < 3600000) { // Less than 1 hour + return Math.floor(diff / 60000) + 'm'; + } else if (diff < 86400000) { // Less than 1 day + return Math.floor(diff / 3600000) + 'h'; + } else { + return date.toLocaleDateString(); + } +} + +function formatMessageTime(timestamp) { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function escapeHtml(unsafe) { + return unsafe + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function showToast(message, type = 'info') { + // Create toast element + const toast = document.createElement('div'); + toast.className = `toast align-items-center text-white bg-${type === 'error' ? 'danger' : 'success'} border-0`; + toast.setAttribute('role', 'alert'); + + toast.innerHTML = ` +
+
+ ${escapeHtml(message)} +
+ +
+ `; + + // Add to page + let toastContainer = document.querySelector('.toast-container'); + if (!toastContainer) { + toastContainer = document.createElement('div'); + toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3'; + document.body.appendChild(toastContainer); + } + + toastContainer.appendChild(toast); + + // Show toast + const bsToast = new bootstrap.Toast(toast); + bsToast.show(); + + // Remove from DOM after hidden + toast.addEventListener('hidden.bs.toast', () => { + toast.remove(); + }); +} + +function showError(message) { + showToast(message, 'error'); +} + +function showSuccess(message) { + showToast(message, 'success'); +} + +// API helper functions +async function apiRequest(url, options = {}) { + try { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('API request failed:', error); + throw error; + } +} + +// File helper functions +function formatFileSize(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function getFileIconClass(fileType) { + if (fileType.startsWith('image/')) return 'fas fa-image'; + if (fileType.startsWith('audio/')) return 'fas fa-music'; + if (fileType.startsWith('video/')) return 'fas fa-video'; + if (fileType.includes('pdf')) return 'fas fa-file-pdf'; + if (fileType.includes('word') || fileType.includes('document')) return 'fas fa-file-word'; + if (fileType.includes('excel') || fileType.includes('sheet')) return 'fas fa-file-excel'; + if (fileType.includes('powerpoint') || fileType.includes('presentation')) return 'fas fa-file-powerpoint'; + if (fileType.includes('zip') || fileType.includes('archive') || fileType.includes('rar') || fileType.includes('7z')) return 'fas fa-file-archive'; + if (fileType.includes('apk')) return 'fab fa-android'; + return 'fas fa-file'; +} + +function getFileIconColor(fileType) { + if (fileType.startsWith('image/')) return 'image'; + if (fileType.startsWith('audio/')) return 'audio'; + if (fileType.startsWith('video/')) return 'video'; + if (fileType.includes('pdf')) return 'pdf'; + if (fileType.includes('word') || fileType.includes('document')) return 'document'; + if (fileType.includes('excel') || fileType.includes('sheet')) return 'document'; + if (fileType.includes('powerpoint') || fileType.includes('presentation')) return 'document'; + if (fileType.includes('zip') || fileType.includes('archive') || fileType.includes('rar') || fileType.includes('7z')) return 'archive'; + if (fileType.includes('apk')) return 'apk'; + return 'default'; +} + +// Online status management +let statusUpdateInterval; + +function startStatusUpdates() { + // Update online status every 30 seconds + statusUpdateInterval = setInterval(updateOnlineStatus, 30000); + + // Update on page visibility change + document.addEventListener('visibilitychange', () => { + if (!document.hidden) { + updateOnlineStatus(); + } + }); + + // Update on page unload + window.addEventListener('beforeunload', () => { + updateOnlineStatus(false); + }); + + // Initial update + updateOnlineStatus(); +} + +async function updateOnlineStatus(online = true) { + try { + await apiRequest('/api/update_status', { + method: 'POST', + body: JSON.stringify({ online }) + }); + } catch (error) { + console.error('Failed to update online status:', error); + } +} + +function stopStatusUpdates() { + if (statusUpdateInterval) { + clearInterval(statusUpdateInterval); + statusUpdateInterval = null; + } +} + +// Initialize common functionality +document.addEventListener('DOMContentLoaded', () => { + // Start status updates if on chat page + if (document.body.classList.contains('chat-page')) { + startStatusUpdates(); + + // Handle mobile keyboard viewport changes + if (/Mobi|Android/i.test(navigator.userAgent)) { + handleMobileViewport(); + } + } + + // Handle page unload + window.addEventListener('beforeunload', () => { + stopStatusUpdates(); + }); +}); + +// Handle mobile viewport changes when keyboard appears/disappears +function handleMobileViewport() { + let initialViewportHeight = window.innerHeight; + + window.addEventListener('resize', () => { + const currentHeight = window.innerHeight; + const chatMessages = document.getElementById('chatMessages'); + + if (chatMessages) { + // If keyboard is shown (viewport shrunk significantly) + if (currentHeight < initialViewportHeight * 0.75) { + // Scroll to bottom when keyboard appears + setTimeout(() => { + chatMessages.scrollTop = chatMessages.scrollHeight; + }, 300); + } + } + }); + + // Handle visual viewport API if available + if (window.visualViewport) { + window.visualViewport.addEventListener('resize', () => { + const chatMessages = document.getElementById('chatMessages'); + if (chatMessages) { + setTimeout(() => { + chatMessages.scrollTop = chatMessages.scrollHeight; + }, 100); + } + }); + } +} + +// Export functions for use in other scripts +window.MainJS = { + formatTime, + formatMessageTime, + escapeHtml, + showToast, + showError, + showSuccess, + apiRequest, + formatFileSize, + getFileIconClass, + getFileIconColor, + updateOnlineStatus +}; diff --git a/templates/chat.html b/templates/chat.html new file mode 100644 index 0000000000000000000000000000000000000000..5d86ba1d2b13609f19c56200ca4d6b72abcd62e0 --- /dev/null +++ b/templates/chat.html @@ -0,0 +1,264 @@ + + + + + + + + + Chat - WhatsApp Clone + + + + + + + + +
+
+ +
+ +
WhatsApp Clone
+
+
+ + + + + +
+
+
+ +

Welcome to WhatsApp Clone

+

Select a conversation to start messaging

+
+ + + +
+ Share files, voice messages, and more +
+
+ + +
+
+
+ + + + + + + + + + + + + + + + + + + + diff --git a/templates/landing.html b/templates/landing.html new file mode 100644 index 0000000000000000000000000000000000000000..6067ac79185876f6e40a466495221e5413d84909 --- /dev/null +++ b/templates/landing.html @@ -0,0 +1,94 @@ + + + + + + WhatsApp Clone + + + + + +
+
+
+
+
+ +
+

WhatsApp Clone

+

Connect with friends and family instantly. Send messages, share files, record voice messages, and stay in touch wherever you are.

+ +
+
+ + Real-time messaging +
+
+ + Group chats (3-10 members) +
+
+ + File sharing & downloads +
+
+ + Voice message recording +
+
+ + Message status tracking +
+
+ + Online/offline status +
+
+ + +
+
+ +
+
+
+
+
+
+
+
+
John Doe
+ online +
+
+
+
+
+
Hello there! 👋
+
+
+
Hi! How are you?
+
+
+
📎 document.pdf
+
+
+
🎵 Voice message
+
+
+
Thanks for sharing! 😊
+
+
+
+
+
+
+
+
+ + + + diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000000000000000000000000000000000000..fa2400bdd5e4e24a65c623adaed96400a53bc918 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,116 @@ + + + + + + Create Account - WhatsApp Clone + + + + + +
+
+
+
+
+ WhatsApp Clone +

Create Account

+

Join our messaging platform

+
+ +
+
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+
+ + + +
+
+
+
+
+ + + + + + diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000000000000000000000000000000000000..dff0d64ac9e234d0cd02dc7c6b8df2add8e47cf9 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,155 @@ + + + + + + Settings - WhatsApp Clone + + + + + +
+
+
+
+
+ + + +

Settings

+
+
+
+
+ {{ user.name[0].upper() }} +
+
{{ user.name }}
+

{{ user.email }}

+
+ +
+
Account Information
+ +
+
+
+
Unique ID
+ Share this ID with others to connect +
+
+ {{ user.unique_id }} + +
+
+
+ +
+
+
+
Name
+ {{ user.name }} +
+ +
+
+ +
+
+
+
Email
+ {{ user.email }} +
+ +
+
+
+ +
+
Features
+ +
+
+
+
File Sharing
+ Share documents, images, and files +
+ +
+
+ +
+
+
+
Voice Messages
+ Record and send audio messages +
+ +
+
+ +
+
+
+
Privacy
+ Manage your privacy settings +
+ +
+
+
+ + +
+
+
+
+
+ + +
+ +
+ + + + + diff --git a/uploads/20240321_125655_20250722_140800.jpg b/uploads/20240321_125655_20250722_140800.jpg new file mode 100644 index 0000000000000000000000000000000000000000..1ce4da328863b5da186673cbe7c4316fc3ac8696 --- /dev/null +++ b/uploads/20240321_125655_20250722_140800.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:09f35e5d1c3ed5d50c570b15634756a410f012aa0b0545db8dc45fe4e4f0c87d +size 2311569 diff --git a/uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_142205.jpeg b/uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_142205.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..ee7cc9537cb00eb8e271ce1ae7e95f239e540c9d --- /dev/null +++ b/uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_142205.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b204d5d44cb4ae47e3e25fdc4ea11c4f1678dbb70bd0507ec9426c7108334e86 +size 163174 diff --git a/uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_143137.jpeg b/uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_143137.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..ee7cc9537cb00eb8e271ce1ae7e95f239e540c9d --- /dev/null +++ b/uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_143137.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b204d5d44cb4ae47e3e25fdc4ea11c4f1678dbb70bd0507ec9426c7108334e86 +size 163174 diff --git a/uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_145007.jpeg b/uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_145007.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..ee7cc9537cb00eb8e271ce1ae7e95f239e540c9d --- /dev/null +++ b/uploads/WhatsApp_Image_2025-06-30_at_11.03.12_PM_20250722_145007.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b204d5d44cb4ae47e3e25fdc4ea11c4f1678dbb70bd0507ec9426c7108334e86 +size 163174 diff --git a/uploads/chalan_form_20250722_140229.jpeg b/uploads/chalan_form_20250722_140229.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..66e407e4e06a422662eae78578d960dcc7c2191f --- /dev/null +++ b/uploads/chalan_form_20250722_140229.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80678237f54fa7eb08d5995bfcff13c3731562928ab562c617072122a58e1787 +size 305573 diff --git a/uploads/esp32-deauther-master_20250722_140414.zip b/uploads/esp32-deauther-master_20250722_140414.zip new file mode 100644 index 0000000000000000000000000000000000000000..28cc111e2da3aaecf8672940460d4eb22c32e533 --- /dev/null +++ b/uploads/esp32-deauther-master_20250722_140414.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:815ca793c847f19019caebead84ad91d89d7ec10f25202c9addd84b2e4ca4bff +size 13822 diff --git a/uploads/esp32-deauther-master_20250722_142306.zip b/uploads/esp32-deauther-master_20250722_142306.zip new file mode 100644 index 0000000000000000000000000000000000000000..28cc111e2da3aaecf8672940460d4eb22c32e533 --- /dev/null +++ b/uploads/esp32-deauther-master_20250722_142306.zip @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:815ca793c847f19019caebead84ad91d89d7ec10f25202c9addd84b2e4ca4bff +size 13822 diff --git a/uploads/voice_20250722_140103.webm b/uploads/voice_20250722_140103.webm new file mode 100644 index 0000000000000000000000000000000000000000..868d0807a079468adf84cefd2e697446c5a54dcc Binary files /dev/null and b/uploads/voice_20250722_140103.webm differ diff --git a/uploads/voice_20250722_142228.webm b/uploads/voice_20250722_142228.webm new file mode 100644 index 0000000000000000000000000000000000000000..07e74d6dbcacb75026f5edff4b0a72e64b38ca4b Binary files /dev/null and b/uploads/voice_20250722_142228.webm differ diff --git a/uploads/voice_20250722_143117.webm b/uploads/voice_20250722_143117.webm new file mode 100644 index 0000000000000000000000000000000000000000..fbc80e970d5a05a43c386a77e3148ee5e78d5d6c Binary files /dev/null and b/uploads/voice_20250722_143117.webm differ diff --git a/uploads/voice_20250722_144446.webm b/uploads/voice_20250722_144446.webm new file mode 100644 index 0000000000000000000000000000000000000000..1b4ea4da49f565d965ae2438d93f73761ad5b0a5 Binary files /dev/null and b/uploads/voice_20250722_144446.webm differ diff --git a/uploads/voice_20250722_145725.webm b/uploads/voice_20250722_145725.webm new file mode 100644 index 0000000000000000000000000000000000000000..dd0cc7c17a1a08dde31878988f2955be8e87f1bd Binary files /dev/null and b/uploads/voice_20250722_145725.webm differ diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000000000000000000000000000000000000..5c49d516ed7f2c2a0061d8672afc1610ca8c8272 --- /dev/null +++ b/uv.lock @@ -0,0 +1,510 @@ +version = 1 +requires-python = ">=3.11" + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "certifi" +version = "2025.7.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/85/4c40d00dcc6284a1c1ad5de5e0996b06f39d8232f1031cd23c2f5c07ee86/charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2", size = 198794 }, + { url = "https://files.pythonhosted.org/packages/41/d9/7a6c0b9db952598e97e93cbdfcb91bacd89b9b88c7c983250a77c008703c/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645", size = 142846 }, + { url = "https://files.pythonhosted.org/packages/66/82/a37989cda2ace7e37f36c1a8ed16c58cf48965a79c2142713244bf945c89/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd", size = 153350 }, + { url = "https://files.pythonhosted.org/packages/df/68/a576b31b694d07b53807269d05ec3f6f1093e9545e8607121995ba7a8313/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8", size = 145657 }, + { url = "https://files.pythonhosted.org/packages/92/9b/ad67f03d74554bed3aefd56fe836e1623a50780f7c998d00ca128924a499/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f", size = 147260 }, + { url = "https://files.pythonhosted.org/packages/a6/e6/8aebae25e328160b20e31a7e9929b1578bbdc7f42e66f46595a432f8539e/charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7", size = 149164 }, + { url = "https://files.pythonhosted.org/packages/8b/f2/b3c2f07dbcc248805f10e67a0262c93308cfa149a4cd3d1fe01f593e5fd2/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9", size = 144571 }, + { url = "https://files.pythonhosted.org/packages/60/5b/c3f3a94bc345bc211622ea59b4bed9ae63c00920e2e8f11824aa5708e8b7/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544", size = 151952 }, + { url = "https://files.pythonhosted.org/packages/e2/4d/ff460c8b474122334c2fa394a3f99a04cf11c646da895f81402ae54f5c42/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82", size = 155959 }, + { url = "https://files.pythonhosted.org/packages/a2/2b/b964c6a2fda88611a1fe3d4c400d39c66a42d6c169c924818c848f922415/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0", size = 153030 }, + { url = "https://files.pythonhosted.org/packages/59/2e/d3b9811db26a5ebf444bc0fa4f4be5aa6d76fc6e1c0fd537b16c14e849b6/charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5", size = 148015 }, + { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106 }, + { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402 }, + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +] + +[[package]] +name = "click" +version = "8.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 }, +] + +[[package]] +name = "flask" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 }, +] + +[[package]] +name = "flask-dance" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "oauthlib" }, + { name = "requests" }, + { name = "requests-oauthlib" }, + { name = "urlobject" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/b3/38aff96fbafe850f7f4186dc06e96ebc29625d68d1427ad65c9d41c4ec9e/flask_dance-7.1.0.tar.gz", hash = "sha256:6d0510e284f3d6ff05af918849791b17ef93a008628ec33f3a80578a44b51674", size = 140993 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/8c/4125e9f1196e5ab9675d38ff445ae4abd7085aba7551335980ac19196389/flask_dance-7.1.0-py3-none-any.whl", hash = "sha256:81599328a2b3604fd4332b3d41a901cf36980c2067e5e38c44ce3b85c4e1ae9c", size = 62176 }, +] + +[[package]] +name = "flask-login" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303 }, +] + +[[package]] +name = "flask-sqlalchemy" +version = "3.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125 }, +] + +[[package]] +name = "greenlet" +version = "3.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/2e/d4fcb2978f826358b673f779f78fa8a32ee37df11920dc2bb5589cbeecef/greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822", size = 270219 }, + { url = "https://files.pythonhosted.org/packages/16/24/929f853e0202130e4fe163bc1d05a671ce8dcd604f790e14896adac43a52/greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83", size = 630383 }, + { url = "https://files.pythonhosted.org/packages/d1/b2/0320715eb61ae70c25ceca2f1d5ae620477d246692d9cc284c13242ec31c/greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf", size = 642422 }, + { url = "https://files.pythonhosted.org/packages/bd/49/445fd1a210f4747fedf77615d941444349c6a3a4a1135bba9701337cd966/greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b", size = 638375 }, + { url = "https://files.pythonhosted.org/packages/7e/c8/ca19760cf6eae75fa8dc32b487e963d863b3ee04a7637da77b616703bc37/greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147", size = 637627 }, + { url = "https://files.pythonhosted.org/packages/65/89/77acf9e3da38e9bcfca881e43b02ed467c1dedc387021fc4d9bd9928afb8/greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5", size = 585502 }, + { url = "https://files.pythonhosted.org/packages/97/c6/ae244d7c95b23b7130136e07a9cc5aadd60d59b5951180dc7dc7e8edaba7/greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc", size = 1114498 }, + { url = "https://files.pythonhosted.org/packages/89/5f/b16dec0cbfd3070658e0d744487919740c6d45eb90946f6787689a7efbce/greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba", size = 1139977 }, + { url = "https://files.pythonhosted.org/packages/66/77/d48fb441b5a71125bcac042fc5b1494c806ccb9a1432ecaa421e72157f77/greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34", size = 297017 }, + { url = "https://files.pythonhosted.org/packages/f3/94/ad0d435f7c48debe960c53b8f60fb41c2026b1d0fa4a99a1cb17c3461e09/greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d", size = 271992 }, + { url = "https://files.pythonhosted.org/packages/93/5d/7c27cf4d003d6e77749d299c7c8f5fd50b4f251647b5c2e97e1f20da0ab5/greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b", size = 638820 }, + { url = "https://files.pythonhosted.org/packages/c6/7e/807e1e9be07a125bb4c169144937910bf59b9d2f6d931578e57f0bce0ae2/greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d", size = 653046 }, + { url = "https://files.pythonhosted.org/packages/9d/ab/158c1a4ea1068bdbc78dba5a3de57e4c7aeb4e7fa034320ea94c688bfb61/greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264", size = 647701 }, + { url = "https://files.pythonhosted.org/packages/cc/0d/93729068259b550d6a0288da4ff72b86ed05626eaf1eb7c0d3466a2571de/greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688", size = 649747 }, + { url = "https://files.pythonhosted.org/packages/f6/f6/c82ac1851c60851302d8581680573245c8fc300253fc1ff741ae74a6c24d/greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb", size = 605461 }, + { url = "https://files.pythonhosted.org/packages/98/82/d022cf25ca39cf1200650fc58c52af32c90f80479c25d1cbf57980ec3065/greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c", size = 1121190 }, + { url = "https://files.pythonhosted.org/packages/f5/e1/25297f70717abe8104c20ecf7af0a5b82d2f5a980eb1ac79f65654799f9f/greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163", size = 1149055 }, + { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817 }, + { url = "https://files.pythonhosted.org/packages/b1/cf/f5c0b23309070ae93de75c90d29300751a5aacefc0a3ed1b1d8edb28f08b/greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad", size = 270732 }, + { url = "https://files.pythonhosted.org/packages/48/ae/91a957ba60482d3fecf9be49bc3948f341d706b52ddb9d83a70d42abd498/greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef", size = 639033 }, + { url = "https://files.pythonhosted.org/packages/6f/df/20ffa66dd5a7a7beffa6451bdb7400d66251374ab40b99981478c69a67a8/greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3", size = 652999 }, + { url = "https://files.pythonhosted.org/packages/51/b4/ebb2c8cb41e521f1d72bf0465f2f9a2fd803f674a88db228887e6847077e/greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95", size = 647368 }, + { url = "https://files.pythonhosted.org/packages/8e/6a/1e1b5aa10dced4ae876a322155705257748108b7fd2e4fae3f2a091fe81a/greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb", size = 650037 }, + { url = "https://files.pythonhosted.org/packages/26/f2/ad51331a157c7015c675702e2d5230c243695c788f8f75feba1af32b3617/greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b", size = 608402 }, + { url = "https://files.pythonhosted.org/packages/26/bc/862bd2083e6b3aff23300900a956f4ea9a4059de337f5c8734346b9b34fc/greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0", size = 1119577 }, + { url = "https://files.pythonhosted.org/packages/86/94/1fc0cc068cfde885170e01de40a619b00eaa8f2916bf3541744730ffb4c3/greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36", size = 1147121 }, + { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603 }, + { url = "https://files.pythonhosted.org/packages/d8/ca/accd7aa5280eb92b70ed9e8f7fd79dc50a2c21d8c73b9a0856f5b564e222/greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86", size = 271479 }, + { url = "https://files.pythonhosted.org/packages/55/71/01ed9895d9eb49223280ecc98a557585edfa56b3d0e965b9fa9f7f06b6d9/greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97", size = 683952 }, + { url = "https://files.pythonhosted.org/packages/ea/61/638c4bdf460c3c678a0a1ef4c200f347dff80719597e53b5edb2fb27ab54/greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728", size = 696917 }, + { url = "https://files.pythonhosted.org/packages/22/cc/0bd1a7eb759d1f3e3cc2d1bc0f0b487ad3cc9f34d74da4b80f226fde4ec3/greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a", size = 692443 }, + { url = "https://files.pythonhosted.org/packages/67/10/b2a4b63d3f08362662e89c103f7fe28894a51ae0bc890fabf37d1d780e52/greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892", size = 692995 }, + { url = "https://files.pythonhosted.org/packages/5a/c6/ad82f148a4e3ce9564056453a71529732baf5448ad53fc323e37efe34f66/greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141", size = 655320 }, + { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 }, +] + +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/28/bbf83e3f76936960b850435576dd5e67034e200469571be53f69174a2dfd/MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d", size = 14353 }, + { url = "https://files.pythonhosted.org/packages/6c/30/316d194b093cde57d448a4c3209f22e3046c5bb2fb0820b118292b334be7/MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93", size = 12392 }, + { url = "https://files.pythonhosted.org/packages/f2/96/9cdafba8445d3a53cae530aaf83c38ec64c4d5427d975c974084af5bc5d2/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832", size = 23984 }, + { url = "https://files.pythonhosted.org/packages/f1/a4/aefb044a2cd8d7334c8a47d3fb2c9f328ac48cb349468cc31c20b539305f/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84", size = 23120 }, + { url = "https://files.pythonhosted.org/packages/8d/21/5e4851379f88f3fad1de30361db501300d4f07bcad047d3cb0449fc51f8c/MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca", size = 23032 }, + { url = "https://files.pythonhosted.org/packages/00/7b/e92c64e079b2d0d7ddf69899c98842f3f9a60a1ae72657c89ce2655c999d/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798", size = 24057 }, + { url = "https://files.pythonhosted.org/packages/f9/ac/46f960ca323037caa0a10662ef97d0a4728e890334fc156b9f9e52bcc4ca/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e", size = 23359 }, + { url = "https://files.pythonhosted.org/packages/69/84/83439e16197337b8b14b6a5b9c2105fff81d42c2a7c5b58ac7b62ee2c3b1/MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4", size = 23306 }, + { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 }, + { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 }, + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348 }, + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149 }, + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118 }, + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993 }, + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178 }, + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319 }, + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352 }, + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 }, + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 }, + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274 }, + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352 }, + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122 }, + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085 }, + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978 }, + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208 }, + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357 }, + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344 }, + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 }, + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 }, + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510 }, + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486 }, + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480 }, + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914 }, + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796 }, + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473 }, + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114 }, + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098 }, + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 }, + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 }, +] + +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/8f/9feb01291d0d7a0a4c6a6bab24094135c2b59c6a81943752f632c75896d6/psycopg2_binary-2.9.10-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:04392983d0bb89a8717772a193cfaac58871321e3ec69514e1c4e0d4957b5aff", size = 3043397 }, + { url = "https://files.pythonhosted.org/packages/15/30/346e4683532011561cd9c8dfeac6a8153dd96452fee0b12666058ab7893c/psycopg2_binary-2.9.10-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:1a6784f0ce3fec4edc64e985865c17778514325074adf5ad8f80636cd029ef7c", size = 3274806 }, + { url = "https://files.pythonhosted.org/packages/66/6e/4efebe76f76aee7ec99166b6c023ff8abdc4e183f7b70913d7c047701b79/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5f86c56eeb91dc3135b3fd8a95dc7ae14c538a2f3ad77a19645cf55bab1799c", size = 2851370 }, + { url = "https://files.pythonhosted.org/packages/7f/fd/ff83313f86b50f7ca089b161b8e0a22bb3c319974096093cd50680433fdb/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b3d2491d4d78b6b14f76881905c7a8a8abcf974aad4a8a0b065273a0ed7a2cb", size = 3080780 }, + { url = "https://files.pythonhosted.org/packages/e6/c4/bfadd202dcda8333a7ccafdc51c541dbdfce7c2c7cda89fa2374455d795f/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2286791ececda3a723d1910441c793be44625d86d1a4e79942751197f4d30341", size = 3264583 }, + { url = "https://files.pythonhosted.org/packages/5d/f1/09f45ac25e704ac954862581f9f9ae21303cc5ded3d0b775532b407f0e90/psycopg2_binary-2.9.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:512d29bb12608891e349af6a0cccedce51677725a921c07dba6342beaf576f9a", size = 3019831 }, + { url = "https://files.pythonhosted.org/packages/9e/2e/9beaea078095cc558f215e38f647c7114987d9febfc25cb2beed7c3582a5/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5a507320c58903967ef7384355a4da7ff3f28132d679aeb23572753cbf2ec10b", size = 2871822 }, + { url = "https://files.pythonhosted.org/packages/01/9e/ef93c5d93f3dc9fc92786ffab39e323b9aed066ba59fdc34cf85e2722271/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6d4fa1079cab9018f4d0bd2db307beaa612b0d13ba73b5c6304b9fe2fb441ff7", size = 2820975 }, + { url = "https://files.pythonhosted.org/packages/a5/f0/049e9631e3268fe4c5a387f6fc27e267ebe199acf1bc1bc9cbde4bd6916c/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:851485a42dbb0bdc1edcdabdb8557c09c9655dfa2ca0460ff210522e073e319e", size = 2919320 }, + { url = "https://files.pythonhosted.org/packages/dc/9a/bcb8773b88e45fb5a5ea8339e2104d82c863a3b8558fbb2aadfe66df86b3/psycopg2_binary-2.9.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:35958ec9e46432d9076286dda67942ed6d968b9c3a6a2fd62b48939d1d78bf68", size = 2957617 }, + { url = "https://files.pythonhosted.org/packages/e2/6b/144336a9bf08a67d217b3af3246abb1d027095dab726f0687f01f43e8c03/psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", size = 1024618 }, + { url = "https://files.pythonhosted.org/packages/61/69/3b3d7bd583c6d3cbe5100802efa5beacaacc86e37b653fc708bf3d6853b8/psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", size = 1163816 }, + { url = "https://files.pythonhosted.org/packages/49/7d/465cc9795cf76f6d329efdafca74693714556ea3891813701ac1fee87545/psycopg2_binary-2.9.10-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:880845dfe1f85d9d5f7c412efea7a08946a46894537e4e5d091732eb1d34d9a0", size = 3044771 }, + { url = "https://files.pythonhosted.org/packages/8b/31/6d225b7b641a1a2148e3ed65e1aa74fc86ba3fee850545e27be9e1de893d/psycopg2_binary-2.9.10-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9440fa522a79356aaa482aa4ba500b65f28e5d0e63b801abf6aa152a29bd842a", size = 3275336 }, + { url = "https://files.pythonhosted.org/packages/30/b7/a68c2b4bff1cbb1728e3ec864b2d92327c77ad52edcd27922535a8366f68/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e3923c1d9870c49a2d44f795df0c889a22380d36ef92440ff618ec315757e539", size = 2851637 }, + { url = "https://files.pythonhosted.org/packages/0b/b1/cfedc0e0e6f9ad61f8657fd173b2f831ce261c02a08c0b09c652b127d813/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b2c956c028ea5de47ff3a8d6b3cc3330ab45cf0b7c3da35a2d6ff8420896526", size = 3082097 }, + { url = "https://files.pythonhosted.org/packages/18/ed/0a8e4153c9b769f59c02fb5e7914f20f0b2483a19dae7bf2db54b743d0d0/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f758ed67cab30b9a8d2833609513ce4d3bd027641673d4ebc9c067e4d208eec1", size = 3264776 }, + { url = "https://files.pythonhosted.org/packages/10/db/d09da68c6a0cdab41566b74e0a6068a425f077169bed0946559b7348ebe9/psycopg2_binary-2.9.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cd9b4f2cfab88ed4a9106192de509464b75a906462fb846b936eabe45c2063e", size = 3020968 }, + { url = "https://files.pythonhosted.org/packages/94/28/4d6f8c255f0dfffb410db2b3f9ac5218d959a66c715c34cac31081e19b95/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6dc08420625b5a20b53551c50deae6e231e6371194fa0651dbe0fb206452ae1f", size = 2872334 }, + { url = "https://files.pythonhosted.org/packages/05/f7/20d7bf796593c4fea95e12119d6cc384ff1f6141a24fbb7df5a668d29d29/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7cd730dfa7c36dbe8724426bf5612798734bff2d3c3857f36f2733f5bfc7c00", size = 2822722 }, + { url = "https://files.pythonhosted.org/packages/4d/e4/0c407ae919ef626dbdb32835a03b6737013c3cc7240169843965cada2bdf/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:155e69561d54d02b3c3209545fb08938e27889ff5a10c19de8d23eb5a41be8a5", size = 2920132 }, + { url = "https://files.pythonhosted.org/packages/2d/70/aa69c9f69cf09a01da224909ff6ce8b68faeef476f00f7ec377e8f03be70/psycopg2_binary-2.9.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c3cc28a6fd5a4a26224007712e79b81dbaee2ffb90ff406256158ec4d7b52b47", size = 2959312 }, + { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191 }, + { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031 }, + { url = "https://files.pythonhosted.org/packages/3e/30/d41d3ba765609c0763505d565c4d12d8f3c79793f0d0f044ff5a28bf395b/psycopg2_binary-2.9.10-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:26540d4a9a4e2b096f1ff9cce51253d0504dca5a85872c7f7be23be5a53eb18d", size = 3044699 }, + { url = "https://files.pythonhosted.org/packages/35/44/257ddadec7ef04536ba71af6bc6a75ec05c5343004a7ec93006bee66c0bc/psycopg2_binary-2.9.10-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:e217ce4d37667df0bc1c397fdcd8de5e81018ef305aed9415c3b093faaeb10fb", size = 3275245 }, + { url = "https://files.pythonhosted.org/packages/1b/11/48ea1cd11de67f9efd7262085588790a95d9dfcd9b8a687d46caf7305c1a/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:245159e7ab20a71d989da00f280ca57da7641fa2cdcf71749c193cea540a74f7", size = 2851631 }, + { url = "https://files.pythonhosted.org/packages/62/e0/62ce5ee650e6c86719d621a761fe4bc846ab9eff8c1f12b1ed5741bf1c9b/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c4ded1a24b20021ebe677b7b08ad10bf09aac197d6943bfe6fec70ac4e4690d", size = 3082140 }, + { url = "https://files.pythonhosted.org/packages/27/ce/63f946c098611f7be234c0dd7cb1ad68b0b5744d34f68062bb3c5aa510c8/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3abb691ff9e57d4a93355f60d4f4c1dd2d68326c968e7db17ea96df3c023ef73", size = 3264762 }, + { url = "https://files.pythonhosted.org/packages/43/25/c603cd81402e69edf7daa59b1602bd41eb9859e2824b8c0855d748366ac9/psycopg2_binary-2.9.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8608c078134f0b3cbd9f89b34bd60a943b23fd33cc5f065e8d5f840061bd0673", size = 3020967 }, + { url = "https://files.pythonhosted.org/packages/5f/d6/8708d8c6fca531057fa170cdde8df870e8b6a9b136e82b361c65e42b841e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:230eeae2d71594103cd5b93fd29d1ace6420d0b86f4778739cb1a5a32f607d1f", size = 2872326 }, + { url = "https://files.pythonhosted.org/packages/ce/ac/5b1ea50fc08a9df82de7e1771537557f07c2632231bbab652c7e22597908/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909", size = 2822712 }, + { url = "https://files.pythonhosted.org/packages/c4/fc/504d4503b2abc4570fac3ca56eb8fed5e437bf9c9ef13f36b6621db8ef00/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1", size = 2920155 }, + { url = "https://files.pythonhosted.org/packages/b2/d1/323581e9273ad2c0dbd1902f3fb50c441da86e894b6e25a73c3fda32c57e/psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567", size = 2959356 }, + { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224 }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 }, +] + +[[package]] +name = "repl-nix-workspace" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "email-validator" }, + { name = "flask" }, + { name = "flask-dance" }, + { name = "flask-login" }, + { name = "flask-sqlalchemy" }, + { name = "gunicorn" }, + { name = "oauthlib" }, + { name = "psycopg2-binary" }, + { name = "pyjwt" }, + { name = "sqlalchemy" }, + { name = "werkzeug" }, +] + +[package.metadata] +requires-dist = [ + { name = "email-validator", specifier = ">=2.2.0" }, + { name = "flask", specifier = ">=3.1.1" }, + { name = "flask-dance", specifier = ">=7.1.0" }, + { name = "flask-login", specifier = ">=0.6.3" }, + { name = "flask-sqlalchemy", specifier = ">=3.1.1" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "oauthlib", specifier = ">=3.3.1" }, + { name = "psycopg2-binary", specifier = ">=2.9.10" }, + { name = "pyjwt", specifier = ">=2.10.1" }, + { name = "sqlalchemy", specifier = ">=2.0.41" }, + { name = "werkzeug", specifier = ">=3.1.3" }, +] + +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, +] + +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.41" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/4e/b00e3ffae32b74b5180e15d2ab4040531ee1bef4c19755fe7926622dc958/sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f", size = 2121232 }, + { url = "https://files.pythonhosted.org/packages/ef/30/6547ebb10875302074a37e1970a5dce7985240665778cfdee2323709f749/sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560", size = 2110897 }, + { url = "https://files.pythonhosted.org/packages/9e/21/59df2b41b0f6c62da55cd64798232d7349a9378befa7f1bb18cf1dfd510a/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f", size = 3273313 }, + { url = "https://files.pythonhosted.org/packages/62/e4/b9a7a0e5c6f79d49bcd6efb6e90d7536dc604dab64582a9dec220dab54b6/sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6", size = 3273807 }, + { url = "https://files.pythonhosted.org/packages/39/d8/79f2427251b44ddee18676c04eab038d043cff0e764d2d8bb08261d6135d/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04", size = 3209632 }, + { url = "https://files.pythonhosted.org/packages/d4/16/730a82dda30765f63e0454918c982fb7193f6b398b31d63c7c3bd3652ae5/sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582", size = 3233642 }, + { url = "https://files.pythonhosted.org/packages/04/61/c0d4607f7799efa8b8ea3c49b4621e861c8f5c41fd4b5b636c534fcb7d73/sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8", size = 2086475 }, + { url = "https://files.pythonhosted.org/packages/9d/8e/8344f8ae1cb6a479d0741c02cd4f666925b2bf02e2468ddaf5ce44111f30/sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504", size = 2110903 }, + { url = "https://files.pythonhosted.org/packages/3e/2a/f1f4e068b371154740dd10fb81afb5240d5af4aa0087b88d8b308b5429c2/sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9", size = 2119645 }, + { url = "https://files.pythonhosted.org/packages/9b/e8/c664a7e73d36fbfc4730f8cf2bf930444ea87270f2825efbe17bf808b998/sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1", size = 2107399 }, + { url = "https://files.pythonhosted.org/packages/5c/78/8a9cf6c5e7135540cb682128d091d6afa1b9e48bd049b0d691bf54114f70/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70", size = 3293269 }, + { url = "https://files.pythonhosted.org/packages/3c/35/f74add3978c20de6323fb11cb5162702670cc7a9420033befb43d8d5b7a4/sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e", size = 3303364 }, + { url = "https://files.pythonhosted.org/packages/6a/d4/c990f37f52c3f7748ebe98883e2a0f7d038108c2c5a82468d1ff3eec50b7/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078", size = 3229072 }, + { url = "https://files.pythonhosted.org/packages/15/69/cab11fecc7eb64bc561011be2bd03d065b762d87add52a4ca0aca2e12904/sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae", size = 3268074 }, + { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514 }, + { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557 }, + { url = "https://files.pythonhosted.org/packages/d3/ad/2e1c6d4f235a97eeef52d0200d8ddda16f6c4dd70ae5ad88c46963440480/sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443", size = 2115491 }, + { url = "https://files.pythonhosted.org/packages/cf/8d/be490e5db8400dacc89056f78a52d44b04fbf75e8439569d5b879623a53b/sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc", size = 2102827 }, + { url = "https://files.pythonhosted.org/packages/a0/72/c97ad430f0b0e78efaf2791342e13ffeafcbb3c06242f01a3bb8fe44f65d/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1", size = 3225224 }, + { url = "https://files.pythonhosted.org/packages/5e/51/5ba9ea3246ea068630acf35a6ba0d181e99f1af1afd17e159eac7e8bc2b8/sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a", size = 3230045 }, + { url = "https://files.pythonhosted.org/packages/78/2f/8c14443b2acea700c62f9b4a8bad9e49fc1b65cfb260edead71fd38e9f19/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d", size = 3159357 }, + { url = "https://files.pythonhosted.org/packages/fc/b2/43eacbf6ccc5276d76cea18cb7c3d73e294d6fb21f9ff8b4eef9b42bbfd5/sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23", size = 3197511 }, + { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 }, + { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 }, + { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 }, +] + +[[package]] +name = "typing-extensions" +version = "4.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 }, +] + +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +] + +[[package]] +name = "urlobject" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/fd/163e6b835b9fabf9c3999f71c5f224daa9d68a38012cccd7ab2a2f861af9/urlobject-3.0.0.tar.gz", hash = "sha256:bfdfe70746d92a039a33e964959bb12cecd9807a434fdb7fef5f38e70a295818", size = 28237 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/38/18c4bbe751a7357b3f6a33352e3af3305ad78f3e72ab7e3d667de4663ed9/urlobject-3.0.0-py3-none-any.whl", hash = "sha256:fd2465520d0a8c5ed983aa47518a2c5bcde0c276a4fd0eb28b0de5dcefd93b1e", size = 16261 }, +] + +[[package]] +name = "werkzeug" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 }, +]