abdullahalioo commited on
Commit
2f7626f
·
verified ·
1 Parent(s): 7d97600

Upload 18 files

Browse files
.replit ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ modules = ["python-3.11"]
2
+
3
+ [nix]
4
+ channel = "stable-24_05"
5
+ packages = ["openssl", "postgresql"]
6
+
7
+ [deployment]
8
+ deploymentTarget = "autoscale"
9
+ run = ["gunicorn", "--bind", "0.0.0.0:5000", "main:app"]
10
+
11
+ [workflows]
12
+ runButton = "Project"
13
+
14
+ [[workflows.workflow]]
15
+ name = "Project"
16
+ mode = "parallel"
17
+ author = "agent"
18
+
19
+ [[workflows.workflow.tasks]]
20
+ task = "workflow.run"
21
+ args = "Start application"
22
+
23
+ [[workflows.workflow]]
24
+ name = "Start application"
25
+ author = "agent"
26
+
27
+ [[workflows.workflow.tasks]]
28
+ task = "shell.exec"
29
+ args = "gunicorn --bind 0.0.0.0:5000 --reuse-port --reload main:app"
30
+ waitForPort = 5000
31
+
32
+ [[ports]]
33
+ localPort = 5000
34
+ externalPort = 80
app.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import logging
3
+ from flask import Flask
4
+ from flask_sqlalchemy import SQLAlchemy
5
+ from sqlalchemy.orm import DeclarativeBase
6
+ from werkzeug.middleware.proxy_fix import ProxyFix
7
+
8
+ # Configure logging
9
+ logging.basicConfig(level=logging.DEBUG)
10
+
11
+ class Base(DeclarativeBase):
12
+ pass
13
+
14
+ # Initialize database
15
+ db = SQLAlchemy(model_class=Base)
16
+
17
+ # Create Flask app
18
+ app = Flask(__name__)
19
+ app.secret_key = os.environ.get("SESSION_SECRET", "dev-secret-key")
20
+ app.wsgi_app = ProxyFix(app.wsgi_app, x_proto=1, x_host=1)
21
+
22
+ # Configure database
23
+ app.config["SQLALCHEMY_DATABASE_URI"] = os.environ.get("DATABASE_URL", "sqlite:///whatsapp_clone.db")
24
+ app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {
25
+ "pool_recycle": 300,
26
+ "pool_pre_ping": True,
27
+ }
28
+
29
+ # File upload configuration
30
+ app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB max file size
31
+ app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads')
32
+ app.config['ALLOWED_EXTENSIONS'] = {
33
+ 'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'tiff', 'psd', 'ai', 'eps',
34
+ 'mp3', 'wav', 'ogg', 'm4a', 'aac', 'flac', 'wma',
35
+ 'mp4', 'avi', 'mov', 'mkv', 'wmv', 'flv', '3gp', 'webm',
36
+ 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp',
37
+ 'zip', 'rar', '7z', 'tar', 'gz', 'bz2',
38
+ 'apk', 'exe', 'dmg', 'deb', 'rpm', 'msi',
39
+ 'html', 'css', 'js', 'json', 'xml', 'csv',
40
+ 'rtf', 'tex', 'md', 'log'
41
+ }
42
+
43
+ # Initialize database with app
44
+ db.init_app(app)
45
+
46
+ # Ensure upload directory exists
47
+ os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
48
+
49
+ # Create tables
50
+ with app.app_context():
51
+ import models # noqa: F401
52
+ db.create_all()
53
+ logging.info("Database tables created")
54
+
55
+ # Import routes
56
+ import routes # noqa: F401
instance/pyproject.toml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "repl-nix-workspace"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ requires-python = ">=3.11"
6
+ dependencies = [
7
+ "email-validator>=2.2.0",
8
+ "flask-dance>=7.1.0",
9
+ "flask>=3.1.1",
10
+ "flask-sqlalchemy>=3.1.1",
11
+ "gunicorn>=23.0.0",
12
+ "psycopg2-binary>=2.9.10",
13
+ "flask-login>=0.6.3",
14
+ "oauthlib>=3.3.1",
15
+ "pyjwt>=2.10.1",
16
+ "sqlalchemy>=2.0.41",
17
+ "werkzeug>=3.1.3",
18
+ ]
main.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from app import app
2
+
3
+ if __name__ == '__main__':
4
+ app.run(host='0.0.0.0', port=5000, debug=True)
models.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from app import db
3
+ import uuid
4
+ import secrets
5
+ import string
6
+
7
+ class User(db.Model):
8
+ __tablename__ = 'users'
9
+
10
+ user_id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
11
+ unique_id = db.Column(db.String(8), unique=True, nullable=False)
12
+ name = db.Column(db.String(100), nullable=False)
13
+ email = db.Column(db.String(120), unique=True, nullable=False)
14
+ online = db.Column(db.Boolean, default=False)
15
+ last_seen = db.Column(db.DateTime, default=datetime.utcnow)
16
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
17
+
18
+ def __init__(self, **kwargs):
19
+ super(User, self).__init__(**kwargs)
20
+ if not self.unique_id:
21
+ self.unique_id = self.generate_unique_id()
22
+
23
+ @staticmethod
24
+ def generate_unique_id():
25
+ """Generate a unique 8-character ID"""
26
+ while True:
27
+ unique_id = ''.join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(8))
28
+ if not User.query.filter_by(unique_id=unique_id).first():
29
+ return unique_id
30
+
31
+ class Conversation(db.Model):
32
+ __tablename__ = 'conversations'
33
+
34
+ id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
35
+ name = db.Column(db.String(200), nullable=False)
36
+ type = db.Column(db.String(20), nullable=False, default='private') # 'private' or 'group'
37
+ created_by = db.Column(db.String(36), db.ForeignKey('users.user_id'), nullable=False)
38
+ created_at = db.Column(db.DateTime, default=datetime.utcnow)
39
+
40
+ def __init__(self, **kwargs):
41
+ super(Conversation, self).__init__(**kwargs)
42
+
43
+ class ConversationParticipant(db.Model):
44
+ __tablename__ = 'conversation_participants'
45
+
46
+ id = db.Column(db.Integer, primary_key=True)
47
+ conversation_id = db.Column(db.String(36), db.ForeignKey('conversations.id'), nullable=False)
48
+ user_id = db.Column(db.String(36), db.ForeignKey('users.user_id'), nullable=False)
49
+ joined_at = db.Column(db.DateTime, default=datetime.utcnow)
50
+
51
+ def __init__(self, **kwargs):
52
+ super(ConversationParticipant, self).__init__(**kwargs)
53
+
54
+ class Message(db.Model):
55
+ __tablename__ = 'messages'
56
+
57
+ id = db.Column(db.String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
58
+ conversation_id = db.Column(db.String(36), db.ForeignKey('conversations.id'), nullable=False)
59
+ sender_id = db.Column(db.String(36), db.ForeignKey('users.user_id'), nullable=False)
60
+ content = db.Column(db.Text)
61
+ message_type = db.Column(db.String(20), default='text') # 'text', 'file', 'audio', 'image'
62
+ file_path = db.Column(db.String(500))
63
+ file_name = db.Column(db.String(255))
64
+ file_size = db.Column(db.Integer)
65
+ file_type = db.Column(db.String(100))
66
+ audio_duration = db.Column(db.Float) # Duration in seconds for audio files
67
+ timestamp = db.Column(db.DateTime, default=datetime.utcnow)
68
+
69
+ # Relationships
70
+ sender = db.relationship('User', backref='sent_messages')
71
+ conversation = db.relationship('Conversation', backref='messages')
72
+
73
+ def __init__(self, **kwargs):
74
+ super(Message, self).__init__(**kwargs)
75
+
76
+ class MessageSeen(db.Model):
77
+ __tablename__ = 'message_seen'
78
+
79
+ id = db.Column(db.Integer, primary_key=True)
80
+ message_id = db.Column(db.String(36), db.ForeignKey('messages.id'), nullable=False)
81
+ user_id = db.Column(db.String(36), db.ForeignKey('users.user_id'), nullable=False)
82
+ seen_at = db.Column(db.DateTime, default=datetime.utcnow)
83
+
84
+ def __init__(self, **kwargs):
85
+ super(MessageSeen, self).__init__(**kwargs)
replit.md ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WhatsApp Clone
2
+
3
+ ## Overview
4
+
5
+ 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.
6
+
7
+ ## User Preferences
8
+
9
+ Preferred communication style: Simple, everyday language.
10
+
11
+ ## System Architecture
12
+
13
+ The application follows a traditional web application architecture with:
14
+
15
+ - **Frontend**: Server-side rendered HTML templates with vanilla JavaScript for interactivity
16
+ - **Backend**: Flask web framework with SQLAlchemy ORM for database operations
17
+ - **Database**: SQLite (default) with PostgreSQL support through environment configuration
18
+ - **File Storage**: Local filesystem storage for uploaded files
19
+ - **Real-time Updates**: Client-side polling mechanism for message updates
20
+
21
+ ## Key Components
22
+
23
+ ### Backend Components
24
+
25
+ 1. **Flask Application** (`app.py`)
26
+ - Main application configuration and setup
27
+ - Database initialization with SQLAlchemy
28
+ - File upload configuration (100MB max, multiple file types supported)
29
+ - Session management with secret key configuration
30
+
31
+ 2. **Database Models** (`models.py`)
32
+ - User model with unique ID generation system
33
+ - Conversation model supporting both private and group chats
34
+ - ConversationParticipant model for managing chat memberships
35
+ - Message model for storing chat messages (incomplete in current codebase)
36
+ - MessageSeen model for tracking message read status (referenced but not fully implemented)
37
+
38
+ 3. **Routes** (`routes.py`)
39
+ - RESTful API endpoints for user registration, messaging, and file operations
40
+ - File upload/download handling with MIME type detection
41
+ - Authentication and session management
42
+ - Conversation and message management endpoints
43
+
44
+ ### Frontend Components
45
+
46
+ 1. **Templates** (`templates/`)
47
+ - Landing page for user onboarding
48
+ - Registration form for new user signup
49
+ - Main chat interface with sidebar and message area
50
+ - Settings page for user account management
51
+
52
+ 2. **Static Assets** (`static/`)
53
+ - CSS styling with responsive design and mobile support
54
+ - JavaScript modules for chat functionality, file handling, and audio recording
55
+ - Modular architecture with separate files for different features
56
+
57
+ ## Data Flow
58
+
59
+ 1. **User Registration**: Users register with name and email, receiving an auto-generated 8-character unique ID
60
+ 2. **Authentication**: Session-based authentication using Flask sessions
61
+ 3. **Conversation Creation**: Users can create private or group conversations (3-10 members)
62
+ 4. **Message Exchange**: Real-time messaging through client-side polling mechanism
63
+ 5. **File Sharing**: Upload and download files with automatic MIME type detection and icon assignment
64
+ 6. **Voice Messages**: Web Audio API integration for recording and playback (in development)
65
+
66
+ ## External Dependencies
67
+
68
+ ### Backend Dependencies
69
+ - **Flask**: Web framework for application structure
70
+ - **SQLAlchemy**: ORM for database operations with declarative base
71
+ - **Werkzeug**: WSGI utilities and security features
72
+ - **ProxyFix**: Middleware for handling proxy headers
73
+
74
+ ### Frontend Dependencies
75
+ - **Bootstrap 5.3.0**: CSS framework for responsive UI design
76
+ - **Font Awesome 6.0.0**: Icon library for UI elements
77
+ - **Web Audio API**: Browser API for voice recording functionality
78
+
79
+ ### Database Support
80
+ - **SQLite**: Default database for development and simple deployments
81
+ - **PostgreSQL**: Production database support through DATABASE_URL environment variable
82
+
83
+ ## Deployment Strategy
84
+
85
+ The application is designed for flexible deployment with:
86
+
87
+ 1. **Environment Configuration**: Database and session secrets configurable via environment variables
88
+ 2. **Proxy Support**: ProxyFix middleware for deployment behind reverse proxies
89
+ 3. **File Storage**: Local filesystem storage with configurable upload directory
90
+ 4. **Database Migration**: Automatic table creation on application startup
91
+ 5. **Debug Mode**: Configurable debug mode for development vs. production
92
+
93
+ ### Key Configuration Options
94
+ - `DATABASE_URL`: Database connection string (defaults to SQLite)
95
+ - `SESSION_SECRET`: Secret key for session management
96
+ - `UPLOAD_FOLDER`: Directory for file uploads
97
+ - `MAX_CONTENT_LENGTH`: Maximum file upload size (100MB default)
98
+
99
+ 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.
routes.py ADDED
@@ -0,0 +1,762 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import mimetypes
3
+ from datetime import datetime
4
+ from flask import render_template, request, jsonify, session, redirect, url_for, send_file, send_from_directory
5
+ from werkzeug.utils import secure_filename
6
+ from sqlalchemy import or_, and_, desc
7
+ from sqlalchemy.orm import joinedload
8
+ from app import app, db
9
+ from models import User, Conversation, ConversationParticipant, Message, MessageSeen
10
+
11
+ def allowed_file(filename):
12
+ return '.' in filename and \
13
+ filename.rsplit('.', 1)[1].lower() in app.config['ALLOWED_EXTENSIONS']
14
+
15
+ def get_file_icon(file_type):
16
+ """Return appropriate Font Awesome icon for file type"""
17
+ if file_type.startswith('image/'):
18
+ return 'fas fa-image'
19
+ elif file_type.startswith('audio/'):
20
+ return 'fas fa-music'
21
+ elif file_type.startswith('video/'):
22
+ return 'fas fa-video'
23
+ elif 'pdf' in file_type:
24
+ return 'fas fa-file-pdf'
25
+ elif any(word in file_type for word in ['word', 'document']):
26
+ return 'fas fa-file-word'
27
+ elif any(word in file_type for word in ['excel', 'sheet']):
28
+ return 'fas fa-file-excel'
29
+ elif any(word in file_type for word in ['powerpoint', 'presentation']):
30
+ return 'fas fa-file-powerpoint'
31
+ elif 'zip' in file_type or 'archive' in file_type:
32
+ return 'fas fa-file-archive'
33
+ elif 'apk' in file_type:
34
+ return 'fab fa-android'
35
+ else:
36
+ return 'fas fa-file'
37
+
38
+ def format_file_size(size_bytes):
39
+ """Convert bytes to human readable format"""
40
+ if size_bytes == 0:
41
+ return "0B"
42
+ size_names = ["B", "KB", "MB", "GB", "TB"]
43
+ i = 0
44
+ while size_bytes >= 1024.0 and i < len(size_names) - 1:
45
+ size_bytes /= 1024.0
46
+ i += 1
47
+ return f"{size_bytes:.1f}{size_names[i]}"
48
+
49
+ @app.route('/')
50
+ def landing():
51
+ if 'user_id' in session:
52
+ return redirect(url_for('chat'))
53
+ return render_template('landing.html')
54
+
55
+ @app.route('/register')
56
+ def register():
57
+ if 'user_id' in session:
58
+ return redirect(url_for('chat'))
59
+ return render_template('register.html')
60
+
61
+ @app.route('/chat')
62
+ def chat():
63
+ if 'user_id' not in session:
64
+ return redirect(url_for('landing'))
65
+
66
+ user = User.query.get(session['user_id'])
67
+ if not user:
68
+ session.clear()
69
+ return redirect(url_for('landing'))
70
+
71
+ # Update user online status
72
+ user.online = True
73
+ user.last_seen = datetime.utcnow()
74
+ db.session.commit()
75
+
76
+ return render_template('chat.html', user=user)
77
+
78
+ @app.route('/settings')
79
+ def settings():
80
+ if 'user_id' not in session:
81
+ return redirect(url_for('landing'))
82
+
83
+ user = User.query.get(session['user_id'])
84
+ if not user:
85
+ session.clear()
86
+ return redirect(url_for('landing'))
87
+
88
+ return render_template('settings.html', user=user)
89
+
90
+ @app.route('/logout')
91
+ def logout():
92
+ if 'user_id' in session:
93
+ user = User.query.get(session['user_id'])
94
+ if user:
95
+ user.online = False
96
+ user.last_seen = datetime.utcnow()
97
+ db.session.commit()
98
+
99
+ session.clear()
100
+ return redirect(url_for('landing'))
101
+
102
+ # API Routes
103
+
104
+ @app.route('/api/register', methods=['POST'])
105
+ def api_register():
106
+ try:
107
+ data = request.get_json()
108
+ name = data.get('name', '').strip()
109
+ email = data.get('email', '').strip().lower()
110
+
111
+ if not name or not email:
112
+ return jsonify({'success': False, 'message': 'Name and email are required'})
113
+
114
+ # Check if email already exists
115
+ if User.query.filter_by(email=email).first():
116
+ return jsonify({'success': False, 'message': 'Email already registered'})
117
+
118
+ # Create new user
119
+ user = User(name=name, email=email)
120
+ db.session.add(user)
121
+ db.session.commit()
122
+
123
+ session['user_id'] = user.user_id
124
+
125
+ return jsonify({'success': True, 'message': 'Account created successfully'})
126
+
127
+ except Exception as e:
128
+ app.logger.error(f"Registration error: {e}")
129
+ return jsonify({'success': False, 'message': 'Registration failed'})
130
+
131
+ @app.route('/api/conversations')
132
+ def api_conversations():
133
+ if 'user_id' not in session:
134
+ return jsonify({'success': False, 'message': 'Not authenticated'})
135
+
136
+ user_id = session['user_id']
137
+
138
+ try:
139
+ # Get conversations where user is a participant
140
+ conversations = db.session.query(Conversation).join(
141
+ ConversationParticipant,
142
+ Conversation.id == ConversationParticipant.conversation_id
143
+ ).filter(
144
+ ConversationParticipant.user_id == user_id
145
+ ).all()
146
+
147
+ result = []
148
+ for conv in conversations:
149
+ # Get other participants
150
+ participants = db.session.query(User).join(
151
+ ConversationParticipant,
152
+ User.user_id == ConversationParticipant.user_id
153
+ ).filter(
154
+ ConversationParticipant.conversation_id == conv.id
155
+ ).all()
156
+
157
+ # Get last message
158
+ last_message = None
159
+ last_msg = Message.query.filter_by(conversation_id=conv.id).order_by(Message.timestamp.desc()).first()
160
+ if last_msg:
161
+ sender = User.query.get(last_msg.sender_id)
162
+ last_message = {
163
+ 'content': last_msg.content or (f"📎 {last_msg.file_name}" if last_msg.message_type == 'file' else
164
+ "🎵 Voice message" if last_msg.message_type == 'audio' else
165
+ "📷 Image" if last_msg.message_type == 'image' else last_msg.content),
166
+ 'timestamp': last_msg.timestamp.isoformat(),
167
+ 'sender_name': sender.name if sender else 'Unknown'
168
+ }
169
+
170
+ # For private chats, use the other participant's info
171
+ if conv.type == 'private':
172
+ other_participant = next((p for p in participants if p.user_id != user_id), None)
173
+ if other_participant:
174
+ conv_name = other_participant.name
175
+ online = other_participant.online
176
+ else:
177
+ conv_name = "Unknown User"
178
+ online = False
179
+ else:
180
+ conv_name = conv.name
181
+ online = any(p.online for p in participants if p.user_id != user_id)
182
+
183
+ result.append({
184
+ 'id': conv.id,
185
+ 'name': conv_name,
186
+ 'type': conv.type,
187
+ 'online': online,
188
+ 'participants': [{'id': p.user_id, 'name': p.name, 'online': p.online} for p in participants],
189
+ 'last_message': last_message
190
+ })
191
+
192
+ # Sort by last message timestamp
193
+ result.sort(key=lambda x: x['last_message']['timestamp'] if x['last_message'] else '1970-01-01T00:00:00', reverse=True)
194
+
195
+ return jsonify({'success': True, 'conversations': result})
196
+
197
+ except Exception as e:
198
+ app.logger.error(f"Error loading conversations: {e}")
199
+ return jsonify({'success': False, 'message': 'Failed to load conversations'})
200
+
201
+ @app.route('/api/messages/<conversation_id>')
202
+ def api_messages(conversation_id):
203
+ if 'user_id' not in session:
204
+ return jsonify({'success': False, 'message': 'Not authenticated'})
205
+
206
+ user_id = session['user_id']
207
+
208
+ try:
209
+ # Verify user is participant in this conversation
210
+ participant = ConversationParticipant.query.filter_by(
211
+ conversation_id=conversation_id,
212
+ user_id=user_id
213
+ ).first()
214
+
215
+ if not participant:
216
+ return jsonify({'success': False, 'message': 'Access denied'})
217
+
218
+ # Get messages
219
+ messages = Message.query.filter_by(
220
+ conversation_id=conversation_id
221
+ ).order_by(Message.timestamp).all()
222
+
223
+ result = []
224
+ for msg in messages:
225
+ message_data = {
226
+ 'id': msg.id,
227
+ 'sender_id': msg.sender_id,
228
+ 'sender_name': (lambda user: user.name if user else 'Unknown')(User.query.get(msg.sender_id)),
229
+ 'content': msg.content,
230
+ 'message_type': msg.message_type,
231
+ 'timestamp': msg.timestamp.isoformat(),
232
+ 'seen_by': [seen.user_id for seen in MessageSeen.query.filter_by(message_id=msg.id).all()]
233
+ }
234
+
235
+ # Add file information if it's a file message
236
+ if msg.message_type in ['file', 'audio', 'image']:
237
+ message_data.update({
238
+ 'file_name': msg.file_name,
239
+ 'file_size': msg.file_size,
240
+ 'file_type': msg.file_type,
241
+ 'file_size_formatted': format_file_size(msg.file_size) if msg.file_size else '0B',
242
+ 'file_icon': get_file_icon(msg.file_type or ''),
243
+ 'audio_duration': msg.audio_duration
244
+ })
245
+
246
+ result.append(message_data)
247
+
248
+ return jsonify({'success': True, 'messages': result})
249
+
250
+ except Exception as e:
251
+ app.logger.error(f"Error loading messages: {e}")
252
+ return jsonify({'success': False, 'message': 'Failed to load messages'})
253
+
254
+ @app.route('/api/send_message', methods=['POST'])
255
+ def api_send_message():
256
+ if 'user_id' not in session:
257
+ return jsonify({'success': False, 'message': 'Not authenticated'})
258
+
259
+ user_id = session['user_id']
260
+
261
+ try:
262
+ data = request.get_json()
263
+ conversation_id = data.get('conversation_id')
264
+ content = data.get('content', '').strip()
265
+
266
+ if not conversation_id or not content:
267
+ return jsonify({'success': False, 'message': 'Conversation ID and content are required'})
268
+
269
+ # Verify user is participant
270
+ participant = ConversationParticipant.query.filter_by(
271
+ conversation_id=conversation_id,
272
+ user_id=user_id
273
+ ).first()
274
+
275
+ if not participant:
276
+ return jsonify({'success': False, 'message': 'Access denied'})
277
+
278
+ # Create message
279
+ message = Message(
280
+ conversation_id=conversation_id,
281
+ sender_id=user_id,
282
+ content=content,
283
+ message_type='text'
284
+ )
285
+
286
+ db.session.add(message)
287
+ db.session.commit()
288
+
289
+ # Get sender info
290
+ sender = User.query.get(user_id)
291
+
292
+ # Return complete message data for instant display
293
+ message_data = {
294
+ 'id': message.id,
295
+ 'sender_id': message.sender_id,
296
+ 'sender_name': sender.name if sender else 'Unknown',
297
+ 'content': message.content,
298
+ 'message_type': message.message_type,
299
+ 'timestamp': message.timestamp.isoformat(),
300
+ 'seen_by': [] # Initially empty
301
+ }
302
+
303
+ return jsonify({'success': True, 'message': message_data})
304
+
305
+ except Exception as e:
306
+ app.logger.error(f"Error sending message: {e}")
307
+ return jsonify({'success': False, 'message': 'Failed to send message'})
308
+
309
+
310
+ @app.route('/api/upload_file', methods=['POST'])
311
+ def api_upload_file():
312
+ if 'user_id' not in session:
313
+ return jsonify({'success': False, 'message': 'Not authenticated'})
314
+
315
+ user_id = session['user_id']
316
+
317
+ try:
318
+ if 'file' not in request.files:
319
+ return jsonify({'success': False, 'message': 'No file uploaded'})
320
+
321
+ file = request.files['file']
322
+ conversation_id = request.form.get('conversation_id')
323
+
324
+ if not conversation_id:
325
+ return jsonify({'success': False, 'message': 'Conversation ID is required'})
326
+
327
+ if file.filename == '':
328
+ return jsonify({'success': False, 'message': 'No file selected'})
329
+
330
+ # Verify user is participant
331
+ participant = ConversationParticipant.query.filter_by(
332
+ conversation_id=conversation_id,
333
+ user_id=user_id
334
+ ).first()
335
+
336
+ if not participant:
337
+ return jsonify({'success': False, 'message': 'Access denied'})
338
+
339
+ if file and file.filename and allowed_file(file.filename):
340
+ filename = secure_filename(file.filename) or 'unnamed_file'
341
+
342
+ # Create unique filename to avoid conflicts
343
+ base_name, ext = os.path.splitext(filename)
344
+ unique_filename = f"{base_name}_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}{ext}"
345
+
346
+ file_path = os.path.join(app.config['UPLOAD_FOLDER'], unique_filename)
347
+ file.save(file_path)
348
+
349
+ # Get file info
350
+ file_size = os.path.getsize(file_path)
351
+ file_type = mimetypes.guess_type(filename)[0] or 'application/octet-stream'
352
+
353
+ # Determine message type based on file type
354
+ message_type = 'file'
355
+ if file_type.startswith('image/'):
356
+ message_type = 'image'
357
+ elif file_type.startswith('audio/'):
358
+ message_type = 'audio'
359
+
360
+ # Create message
361
+ message = Message(
362
+ conversation_id=conversation_id,
363
+ sender_id=user_id,
364
+ content=f"📎 {filename}",
365
+ message_type=message_type,
366
+ file_path=unique_filename,
367
+ file_name=filename,
368
+ file_size=file_size,
369
+ file_type=file_type
370
+ )
371
+
372
+ db.session.add(message)
373
+ db.session.commit()
374
+
375
+ return jsonify({'success': True, 'message': 'File uploaded successfully'})
376
+ else:
377
+ return jsonify({'success': False, 'message': 'File type not allowed'})
378
+
379
+ except Exception as e:
380
+ app.logger.error(f"Error uploading file: {e}")
381
+ return jsonify({'success': False, 'message': 'Failed to upload file'})
382
+
383
+ @app.route('/api/upload_audio', methods=['POST'])
384
+ def api_upload_audio():
385
+ if 'user_id' not in session:
386
+ return jsonify({'success': False, 'message': 'Not authenticated'})
387
+
388
+ user_id = session['user_id']
389
+
390
+ try:
391
+ if 'audio' not in request.files:
392
+ return jsonify({'success': False, 'message': 'No audio file uploaded'})
393
+
394
+ audio_file = request.files['audio']
395
+ conversation_id = request.form.get('conversation_id')
396
+ duration = float(request.form.get('duration', 0))
397
+
398
+ if not conversation_id:
399
+ return jsonify({'success': False, 'message': 'Conversation ID is required'})
400
+
401
+ # Verify user is participant
402
+ participant = ConversationParticipant.query.filter_by(
403
+ conversation_id=conversation_id,
404
+ user_id=user_id
405
+ ).first()
406
+
407
+ if not participant:
408
+ return jsonify({'success': False, 'message': 'Access denied'})
409
+
410
+ # Create unique filename
411
+ filename = f"voice_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.webm"
412
+ file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
413
+ audio_file.save(file_path)
414
+
415
+ # Get file info
416
+ file_size = os.path.getsize(file_path)
417
+
418
+ # Create message
419
+ message = Message(
420
+ conversation_id=conversation_id,
421
+ sender_id=user_id,
422
+ content="🎵 Voice message",
423
+ message_type='audio',
424
+ file_path=filename,
425
+ file_name="Voice message",
426
+ file_size=file_size,
427
+ file_type='audio/webm',
428
+ audio_duration=duration
429
+ )
430
+
431
+ db.session.add(message)
432
+ db.session.commit()
433
+
434
+ return jsonify({'success': True, 'message': 'Voice message sent'})
435
+
436
+ except Exception as e:
437
+ app.logger.error(f"Error uploading audio: {e}")
438
+ return jsonify({'success': False, 'message': 'Failed to send voice message'})
439
+
440
+ @app.route('/api/download/<message_id>')
441
+ def api_download(message_id):
442
+ if 'user_id' not in session:
443
+ return jsonify({'success': False, 'message': 'Not authenticated'}), 401
444
+
445
+ user_id = session['user_id']
446
+
447
+ try:
448
+ # Get message
449
+ message = Message.query.get(message_id)
450
+ if not message or not message.file_path:
451
+ return jsonify({'success': False, 'message': 'File not found'}), 404
452
+
453
+ # Verify user has access to this conversation
454
+ participant = ConversationParticipant.query.filter_by(
455
+ conversation_id=message.conversation_id,
456
+ user_id=user_id
457
+ ).first()
458
+
459
+ if not participant:
460
+ return jsonify({'success': False, 'message': 'Access denied'}), 403
461
+
462
+ file_path = os.path.join(app.config['UPLOAD_FOLDER'], message.file_path)
463
+
464
+ if not os.path.exists(file_path):
465
+ return jsonify({'success': False, 'message': 'File not found on server'}), 404
466
+
467
+ return send_file(
468
+ file_path,
469
+ as_attachment=True,
470
+ download_name=message.file_name,
471
+ mimetype=message.file_type
472
+ )
473
+
474
+ except Exception as e:
475
+ app.logger.error(f"Error downloading file: {e}")
476
+ return jsonify({'success': False, 'message': 'Download failed'}), 500
477
+
478
+ @app.route('/api/find_user', methods=['POST'])
479
+ def api_find_user():
480
+ if 'user_id' not in session:
481
+ return jsonify({'success': False, 'message': 'Not authenticated'})
482
+
483
+ try:
484
+ data = request.get_json()
485
+ unique_id = data.get('unique_id', '').strip().upper()
486
+
487
+ if not unique_id:
488
+ return jsonify({'success': False, 'message': 'User ID is required'})
489
+
490
+ user = User.query.filter_by(unique_id=unique_id).first()
491
+ if not user:
492
+ return jsonify({'success': False, 'message': 'User not found'})
493
+
494
+ if user.user_id == session['user_id']:
495
+ return jsonify({'success': False, 'message': 'Cannot start chat with yourself'})
496
+
497
+ return jsonify({
498
+ 'success': True,
499
+ 'user': {
500
+ 'user_id': user.user_id,
501
+ 'name': user.name,
502
+ 'unique_id': user.unique_id,
503
+ 'online': user.online
504
+ }
505
+ })
506
+
507
+ except Exception as e:
508
+ app.logger.error(f"Error finding user: {e}")
509
+ return jsonify({'success': False, 'message': 'Search failed'})
510
+
511
+ @app.route('/api/start_private_chat', methods=['POST'])
512
+ def api_start_private_chat():
513
+ if 'user_id' not in session:
514
+ return jsonify({'success': False, 'message': 'Not authenticated'})
515
+
516
+ user_id = session['user_id']
517
+
518
+ try:
519
+ data = request.get_json()
520
+ other_user_id = data.get('user_id')
521
+
522
+ if not other_user_id:
523
+ return jsonify({'success': False, 'message': 'User ID is required'})
524
+
525
+ if other_user_id == user_id:
526
+ return jsonify({'success': False, 'message': 'Cannot start chat with yourself'})
527
+
528
+ # Check if conversation already exists
529
+ existing_conv = db.session.query(Conversation).join(
530
+ ConversationParticipant, Conversation.id == ConversationParticipant.conversation_id
531
+ ).filter(
532
+ Conversation.type == 'private',
533
+ ConversationParticipant.user_id.in_([user_id, other_user_id])
534
+ ).group_by(Conversation.id).having(
535
+ db.func.count(ConversationParticipant.user_id) == 2
536
+ ).first()
537
+
538
+ if existing_conv:
539
+ # Check if both users are participants
540
+ participants = ConversationParticipant.query.filter_by(conversation_id=existing_conv.id).all()
541
+ participant_ids = [p.user_id for p in participants]
542
+ if set(participant_ids) == {user_id, other_user_id}:
543
+ return jsonify({'success': True, 'conversation_id': existing_conv.id})
544
+
545
+ # Create new conversation
546
+ other_user = User.query.get(other_user_id)
547
+ if not other_user:
548
+ return jsonify({'success': False, 'message': 'User not found'})
549
+
550
+ conversation = Conversation(
551
+ name=f"Private chat with {other_user.name}",
552
+ type='private',
553
+ created_by=user_id
554
+ )
555
+
556
+ db.session.add(conversation)
557
+ db.session.flush() # Get the ID
558
+
559
+ # Add participants
560
+ participant1 = ConversationParticipant(conversation_id=conversation.id, user_id=user_id)
561
+ participant2 = ConversationParticipant(conversation_id=conversation.id, user_id=other_user_id)
562
+
563
+ db.session.add(participant1)
564
+ db.session.add(participant2)
565
+ db.session.commit()
566
+
567
+ return jsonify({'success': True, 'conversation_id': conversation.id})
568
+
569
+ except Exception as e:
570
+ app.logger.error(f"Error starting private chat: {e}")
571
+ return jsonify({'success': False, 'message': 'Failed to start chat'})
572
+
573
+ @app.route('/api/create_group', methods=['POST'])
574
+ def api_create_group():
575
+ if 'user_id' not in session:
576
+ return jsonify({'success': False, 'message': 'Not authenticated'})
577
+
578
+ user_id = session['user_id']
579
+
580
+ try:
581
+ data = request.get_json()
582
+ group_name = data.get('name', '').strip()
583
+ member_ids = data.get('members', [])
584
+
585
+ if not group_name:
586
+ return jsonify({'success': False, 'message': 'Group name is required'})
587
+
588
+ if len(member_ids) < 1 or len(member_ids) > 9:
589
+ return jsonify({'success': False, 'message': 'Group must have 2-10 members (including you)'})
590
+
591
+ # Verify all members exist
592
+ members = User.query.filter(User.unique_id.in_(member_ids)).all()
593
+ if len(members) != len(member_ids):
594
+ return jsonify({'success': False, 'message': 'Some members not found'})
595
+
596
+ # Create conversation
597
+ conversation = Conversation(
598
+ name=group_name,
599
+ type='group',
600
+ created_by=user_id
601
+ )
602
+
603
+ db.session.add(conversation)
604
+ db.session.flush() # Get the ID
605
+
606
+ # Add creator as participant
607
+ creator_participant = ConversationParticipant(conversation_id=conversation.id, user_id=user_id)
608
+ db.session.add(creator_participant)
609
+
610
+ # Add other participants
611
+ for member in members:
612
+ if member.user_id != user_id: # Don't add creator twice
613
+ participant = ConversationParticipant(conversation_id=conversation.id, user_id=member.user_id)
614
+ db.session.add(participant)
615
+
616
+ db.session.commit()
617
+
618
+ return jsonify({'success': True, 'conversation_id': conversation.id})
619
+
620
+ except Exception as e:
621
+ app.logger.error(f"Error creating group: {e}")
622
+ return jsonify({'success': False, 'message': 'Failed to create group'})
623
+
624
+ @app.route('/api/update_status', methods=['POST'])
625
+ def api_update_status():
626
+ if 'user_id' not in session:
627
+ return jsonify({'success': False, 'message': 'Not authenticated'})
628
+
629
+ try:
630
+ user = User.query.get(session['user_id'])
631
+ if user:
632
+ data = request.get_json()
633
+ user.online = data.get('online', True)
634
+ user.last_seen = datetime.utcnow()
635
+ db.session.commit()
636
+
637
+ return jsonify({'success': True})
638
+
639
+ except Exception as e:
640
+ app.logger.error(f"Error updating status: {e}")
641
+ return jsonify({'success': False, 'message': 'Failed to update status'})
642
+
643
+ # Double Blue Tick System - Mark messages as seen
644
+ @app.route('/api/mark_seen', methods=['POST'])
645
+ def api_mark_seen():
646
+ if 'user_id' not in session:
647
+ return jsonify({'success': False, 'message': 'Not authenticated'})
648
+
649
+ user_id = session['user_id']
650
+
651
+ try:
652
+ data = request.get_json()
653
+ message_ids = data.get('message_ids', [])
654
+
655
+ if not message_ids:
656
+ return jsonify({'success': False, 'message': 'Message IDs required'})
657
+
658
+ # Mark messages as seen for this user
659
+ for message_id in message_ids:
660
+ # Check if already seen
661
+ existing_seen = MessageSeen.query.filter_by(
662
+ message_id=message_id,
663
+ user_id=user_id
664
+ ).first()
665
+
666
+ if not existing_seen:
667
+ message_seen = MessageSeen(
668
+ message_id=message_id,
669
+ user_id=user_id
670
+ )
671
+ db.session.add(message_seen)
672
+
673
+ db.session.commit()
674
+ return jsonify({'success': True})
675
+
676
+ except Exception as e:
677
+ app.logger.error(f"Error marking messages as seen: {e}")
678
+ return jsonify({'success': False, 'message': 'Failed to mark messages as seen'})
679
+
680
+ # Get message seen status for double blue tick display
681
+ @app.route('/api/message_status/<message_id>')
682
+ def api_message_status(message_id):
683
+ if 'user_id' not in session:
684
+ return jsonify({'success': False, 'message': 'Not authenticated'})
685
+
686
+ try:
687
+ # Get message
688
+ message = Message.query.get(message_id)
689
+ if not message:
690
+ return jsonify({'success': False, 'message': 'Message not found'})
691
+
692
+ # Check if user has access to this conversation
693
+ participant = ConversationParticipant.query.filter_by(
694
+ conversation_id=message.conversation_id,
695
+ user_id=session['user_id']
696
+ ).first()
697
+
698
+ if not participant:
699
+ return jsonify({'success': False, 'message': 'Access denied'})
700
+
701
+ # Get all participants in the conversation except sender
702
+ conversation_participants = ConversationParticipant.query.filter(
703
+ ConversationParticipant.conversation_id == message.conversation_id,
704
+ ConversationParticipant.user_id != message.sender_id
705
+ ).all()
706
+
707
+ # Count how many have seen the message
708
+ seen_count = MessageSeen.query.filter_by(message_id=message_id).count()
709
+ total_recipients = len(conversation_participants)
710
+
711
+ # Determine status: sent (1 tick), delivered (2 gray ticks), seen (2 blue ticks)
712
+ if seen_count == 0:
713
+ status = 'delivered' # 2 gray ticks
714
+ elif seen_count == total_recipients:
715
+ status = 'seen' # 2 blue ticks
716
+ else:
717
+ status = 'partially_seen' # 2 blue ticks
718
+
719
+ return jsonify({
720
+ 'success': True,
721
+ 'status': status,
722
+ 'seen_count': seen_count,
723
+ 'total_recipients': total_recipients
724
+ })
725
+
726
+ except Exception as e:
727
+ app.logger.error(f"Error getting message status: {e}")
728
+ return jsonify({'success': False, 'message': 'Failed to get message status'})
729
+
730
+ # Image preview endpoint for WhatsApp-like image viewing
731
+ @app.route('/api/image/<message_id>')
732
+ def api_view_image(message_id):
733
+ if 'user_id' not in session:
734
+ return jsonify({'success': False, 'message': 'Not authenticated'}), 401
735
+
736
+ user_id = session['user_id']
737
+
738
+ try:
739
+ # Get message
740
+ message = Message.query.get(message_id)
741
+ if not message or message.message_type != 'image':
742
+ return jsonify({'success': False, 'message': 'Image not found'}), 404
743
+
744
+ # Verify user has access to this conversation
745
+ participant = ConversationParticipant.query.filter_by(
746
+ conversation_id=message.conversation_id,
747
+ user_id=user_id
748
+ ).first()
749
+
750
+ if not participant:
751
+ return jsonify({'success': False, 'message': 'Access denied'}), 403
752
+
753
+ # Return image file
754
+ file_path = os.path.join(app.config['UPLOAD_FOLDER'], message.file_path)
755
+ if os.path.exists(file_path):
756
+ return send_file(file_path)
757
+ else:
758
+ return jsonify({'success': False, 'message': 'File not found on server'}), 404
759
+
760
+ except Exception as e:
761
+ app.logger.error(f"Error viewing image: {e}")
762
+ return jsonify({'success': False, 'message': 'Failed to load image'}), 500
static/css/style.css ADDED
@@ -0,0 +1,946 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* General Styles */
2
+ * {
3
+ box-sizing: border-box;
4
+ }
5
+
6
+ body {
7
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
8
+ background-color: #f0f2f5;
9
+ margin: 0;
10
+ padding: 0;
11
+ height: 100vh;
12
+ overflow: hidden;
13
+ transition: all 0.2s ease;
14
+ }
15
+
16
+ /* Smooth transitions for UI elements */
17
+ .conversation-item,
18
+ .message-item,
19
+ .message-bubble,
20
+ .btn,
21
+ input,
22
+ textarea {
23
+ transition: all 0.2s ease;
24
+ }
25
+
26
+ /* Touch optimization for mobile */
27
+ .conversation-item,
28
+ .btn,
29
+ button {
30
+ -webkit-tap-highlight-color: transparent;
31
+ -webkit-touch-callout: none;
32
+ -webkit-user-select: none;
33
+ user-select: none;
34
+ }
35
+
36
+ /* Ensure touch works properly always */
37
+ * {
38
+ touch-action: manipulation;
39
+ }
40
+
41
+ body {
42
+ touch-action: manipulation;
43
+ }
44
+
45
+ /* Image preview specific touch handling */
46
+ .image-preview-modal {
47
+ touch-action: none; /* Only for the modal itself */
48
+ }
49
+
50
+ .image-preview-modal img {
51
+ touch-action: auto; /* Allow zooming/panning on images */
52
+ }
53
+
54
+ .h-100 {
55
+ height: 100vh !important;
56
+ }
57
+
58
+ /* Chat Page Layout */
59
+ .chat-page {
60
+ height: 100vh;
61
+ overflow: hidden;
62
+ }
63
+
64
+ .chat-page .container-fluid {
65
+ height: 100vh;
66
+ max-height: 100vh;
67
+ }
68
+
69
+ .chat-page .row {
70
+ height: 100vh;
71
+ max-height: 100vh;
72
+ }
73
+
74
+ .sidebar {
75
+ background: white;
76
+ border-right: 1px solid #e9ecef;
77
+ height: 100vh;
78
+ overflow-y: auto;
79
+ display: flex;
80
+ flex-direction: column;
81
+ }
82
+
83
+ /* Fix for all screen sizes 769px and above */
84
+ @media (min-width: 769px) {
85
+ .chat-page .sidebar {
86
+ display: flex !important;
87
+ position: relative !important;
88
+ height: 100vh !important;
89
+ max-height: 100vh !important;
90
+ width: 33.33333% !important;
91
+ flex: 0 0 auto !important;
92
+ }
93
+
94
+ .chat-page .chat-area {
95
+ display: block !important;
96
+ height: 100vh !important;
97
+ max-height: 100vh !important;
98
+ width: 66.66667% !important;
99
+ flex: 0 0 auto !important;
100
+ }
101
+
102
+ .chat-page .col-md-4 {
103
+ width: 33.33333% !important;
104
+ flex: 0 0 33.33333% !important;
105
+ }
106
+
107
+ .chat-page .col-md-8 {
108
+ width: 66.66667% !important;
109
+ flex: 0 0 66.66667% !important;
110
+ }
111
+ }
112
+
113
+ /* Image Preview Modal (WhatsApp-like) */
114
+ .image-preview-modal {
115
+ position: fixed;
116
+ top: 0;
117
+ left: 0;
118
+ width: 100%;
119
+ height: 100%;
120
+ z-index: 9999;
121
+ display: flex;
122
+ align-items: center;
123
+ justify-content: center;
124
+ }
125
+
126
+ .image-preview-overlay {
127
+ position: absolute;
128
+ top: 0;
129
+ left: 0;
130
+ width: 100%;
131
+ height: 100%;
132
+ background: rgba(0, 0, 0, 0.9);
133
+ display: flex;
134
+ align-items: center;
135
+ justify-content: center;
136
+ }
137
+
138
+ .image-preview-container {
139
+ position: relative;
140
+ max-width: 90vw;
141
+ max-height: 90vh;
142
+ display: flex;
143
+ flex-direction: column;
144
+ align-items: center;
145
+ }
146
+
147
+ .image-preview-close {
148
+ position: absolute;
149
+ top: -50px;
150
+ right: 0;
151
+ background: none;
152
+ border: none;
153
+ color: white;
154
+ font-size: 24px;
155
+ cursor: pointer;
156
+ z-index: 10000;
157
+ padding: 10px;
158
+ }
159
+
160
+ .image-preview-close:hover {
161
+ color: #ccc;
162
+ }
163
+
164
+ .image-preview-image {
165
+ max-width: 100%;
166
+ max-height: 80vh;
167
+ object-fit: contain;
168
+ border-radius: 8px;
169
+ }
170
+
171
+ .image-preview-actions {
172
+ margin-top: 20px;
173
+ display: flex;
174
+ gap: 10px;
175
+ }
176
+
177
+ /* Message Status Icons (Double Blue Tick) */
178
+ .message-status {
179
+ margin-left: 5px;
180
+ font-size: 12px;
181
+ }
182
+
183
+ .message-status.sent {
184
+ color: #95a5a6;
185
+ }
186
+
187
+ .message-status.delivered {
188
+ color: #95a5a6;
189
+ }
190
+
191
+ .message-status.seen {
192
+ color: #25d366; /* WhatsApp blue tick color */
193
+ }
194
+
195
+ /* Image Message Styling */
196
+ .image-message {
197
+ position: relative;
198
+ cursor: pointer;
199
+ border-radius: 12px;
200
+ overflow: hidden;
201
+ max-width: 280px;
202
+ }
203
+
204
+ .message-image {
205
+ width: 100%;
206
+ height: auto;
207
+ display: block;
208
+ border-radius: 12px;
209
+ }
210
+
211
+ .image-overlay {
212
+ position: absolute;
213
+ top: 0;
214
+ left: 0;
215
+ width: 100%;
216
+ height: 100%;
217
+ background: rgba(0, 0, 0, 0);
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: center;
221
+ color: white;
222
+ font-size: 24px;
223
+ transition: all 0.3s ease;
224
+ opacity: 0;
225
+ }
226
+
227
+ .image-message:hover .image-overlay {
228
+ background: rgba(0, 0, 0, 0.3);
229
+ opacity: 1;
230
+ }
231
+
232
+ /* Also fix the md-4 and md-8 at exactly 768px */
233
+ @media (min-width: 768px) and (max-width: 768px) {
234
+ .chat-page .sidebar {
235
+ display: flex !important;
236
+ position: relative !important;
237
+ height: 100vh !important;
238
+ }
239
+
240
+ .chat-page .chat-area {
241
+ display: block !important;
242
+ height: 100vh !important;
243
+ }
244
+ }
245
+
246
+ .sidebar-header {
247
+ background: #25d366;
248
+ color: white;
249
+ padding: 1rem;
250
+ flex-shrink: 0;
251
+ }
252
+
253
+ .search-box {
254
+ padding: 0.5rem;
255
+ flex-shrink: 0;
256
+ background: #f0f2f5;
257
+ }
258
+
259
+ .new-chat-btn {
260
+ padding: 0.5rem;
261
+ flex-shrink: 0;
262
+ background: #f0f2f5;
263
+ }
264
+
265
+ .conversations-list {
266
+ flex: 1;
267
+ overflow-y: auto;
268
+ }
269
+
270
+ /* Mobile First Approach */
271
+ html {
272
+ font-size: 16px;
273
+ }
274
+
275
+ @media (max-width: 768px) {
276
+ html {
277
+ font-size: 14px;
278
+ }
279
+ }
280
+
281
+ /* Landing Page */
282
+ .landing-page {
283
+ background: linear-gradient(135deg, #25d366 0%, #128c7e 100%);
284
+ color: white;
285
+ overflow: auto;
286
+ min-height: 100vh;
287
+ }
288
+
289
+ .landing-content {
290
+ max-width: 500px;
291
+ padding: 1rem;
292
+ }
293
+
294
+ .logo {
295
+ width: 120px;
296
+ height: 120px;
297
+ filter: drop-shadow(0 4px 8px rgba(0,0,0,0.2));
298
+ }
299
+
300
+ .logo-small {
301
+ width: 60px;
302
+ height: 60px;
303
+ }
304
+
305
+ .feature-item {
306
+ justify-content: flex-start;
307
+ text-align: left;
308
+ font-size: 1.1rem;
309
+ }
310
+
311
+ .phone-mockup {
312
+ width: 300px;
313
+ height: 600px;
314
+ background: #000;
315
+ border-radius: 30px;
316
+ padding: 20px;
317
+ box-shadow: 0 20px 40px rgba(0,0,0,0.3);
318
+ }
319
+
320
+ .phone-screen {
321
+ width: 100%;
322
+ height: 100%;
323
+ background: white;
324
+ border-radius: 20px;
325
+ overflow: hidden;
326
+ display: flex;
327
+ flex-direction: column;
328
+ }
329
+
330
+ .chat-header {
331
+ background: #25d366;
332
+ color: white;
333
+ padding: 15px;
334
+ flex-shrink: 0;
335
+ }
336
+
337
+ .chat-messages {
338
+ flex: 1;
339
+ padding: 20px;
340
+ overflow-y: auto;
341
+ }
342
+
343
+ .message {
344
+ margin-bottom: 15px;
345
+ }
346
+
347
+ .message.sent {
348
+ text-align: right;
349
+ }
350
+
351
+ .message-bubble {
352
+ display: inline-block;
353
+ padding: 8px 12px;
354
+ border-radius: 18px;
355
+ max-width: 80%;
356
+ word-wrap: break-word;
357
+ }
358
+
359
+ .message.received .message-bubble {
360
+ background: #f1f1f1;
361
+ color: #333;
362
+ }
363
+
364
+ .message.sent .message-bubble {
365
+ background: #dcf8c6;
366
+ color: #333;
367
+ }
368
+
369
+ /* Register Page */
370
+ .register-page {
371
+ background: linear-gradient(135deg, #25d366 0%, #128c7e 100%);
372
+ overflow: auto;
373
+ min-height: 100vh;
374
+ padding: 1rem;
375
+ }
376
+
377
+ .register-card {
378
+ background: white;
379
+ padding: 2rem;
380
+ border-radius: 15px;
381
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
382
+ width: 100%;
383
+ max-width: 500px;
384
+ margin: 0 auto;
385
+ }
386
+
387
+ .conversation-item {
388
+ padding: 0.75rem 1rem;
389
+ border-bottom: 1px solid #e9ecef;
390
+ cursor: pointer;
391
+ transition: all 0.15s ease;
392
+ position: relative;
393
+ }
394
+
395
+ .conversation-item:hover {
396
+ background-color: #f8f9fa;
397
+ transform: translateX(2px);
398
+ }
399
+
400
+ .conversation-item.active {
401
+ background-color: #e3f2fd;
402
+ border-left: 4px solid #25d366;
403
+ transform: translateX(4px);
404
+ }
405
+
406
+ .conversation-item:active {
407
+ transform: scale(0.98);
408
+ }
409
+
410
+ .chat-area {
411
+ background: #e5ddd5;
412
+ height: 100vh;
413
+ position: relative;
414
+ width: 100%;
415
+ }
416
+
417
+ .welcome-screen {
418
+ height: 100%;
419
+ display: flex;
420
+ align-items: center;
421
+ justify-content: center;
422
+ background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="2" fill="%23ffffff" opacity="0.1"/></svg>') repeat;
423
+ }
424
+
425
+ .chat-container {
426
+ height: 100%;
427
+ display: flex;
428
+ flex-direction: column;
429
+ }
430
+
431
+ .chat-header {
432
+ background: #25d366;
433
+ color: white;
434
+ padding: 1rem;
435
+ flex-shrink: 0;
436
+ border-bottom: 1px solid #128c7e;
437
+ }
438
+
439
+ .chat-messages {
440
+ flex: 1;
441
+ padding: 1rem;
442
+ overflow-y: auto;
443
+ background: #e5ddd5;
444
+ background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="1" fill="%23ffffff" opacity="0.05"/></svg>');
445
+ }
446
+
447
+ .chat-input {
448
+ background: #f0f2f5;
449
+ padding: 0.75rem 1rem;
450
+ flex-shrink: 0;
451
+ }
452
+
453
+ .chat-input-toolbar {
454
+ display: flex;
455
+ align-items: center;
456
+ }
457
+
458
+ .message-item {
459
+ margin-bottom: 1rem;
460
+ display: flex;
461
+ align-items: flex-end;
462
+ }
463
+
464
+ .message-item.sent {
465
+ justify-content: flex-end;
466
+ }
467
+
468
+ .message-content {
469
+ max-width: 70%;
470
+ position: relative;
471
+ }
472
+
473
+ .message-bubble {
474
+ padding: 8px 12px;
475
+ border-radius: 18px;
476
+ word-wrap: break-word;
477
+ position: relative;
478
+ }
479
+
480
+ .message-item.received .message-bubble {
481
+ background: white;
482
+ color: #333;
483
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
484
+ }
485
+
486
+ .message-item.sent .message-bubble {
487
+ background: #dcf8c6;
488
+ color: #333;
489
+ box-shadow: 0 1px 2px rgba(0,0,0,0.1);
490
+ }
491
+
492
+ .message-time {
493
+ font-size: 0.75rem;
494
+ color: #666;
495
+ margin-top: 4px;
496
+ text-align: right;
497
+ }
498
+
499
+ .message-status {
500
+ margin-left: 4px;
501
+ }
502
+
503
+ .message-status.sent {
504
+ color: #999;
505
+ }
506
+
507
+ .message-status.delivered {
508
+ color: #4fc3f7;
509
+ }
510
+
511
+ .message-status.seen {
512
+ color: #25d366;
513
+ }
514
+
515
+ /* File Message Styles */
516
+ .file-message {
517
+ background: white;
518
+ border: 1px solid #e9ecef;
519
+ border-radius: 12px;
520
+ padding: 12px;
521
+ max-width: 300px;
522
+ cursor: pointer;
523
+ transition: background-color 0.2s;
524
+ }
525
+
526
+ .file-message:hover {
527
+ background-color: #f8f9fa;
528
+ }
529
+
530
+ .file-message.sent {
531
+ background: #dcf8c6;
532
+ border-color: #c5d9a5;
533
+ }
534
+
535
+ .file-info {
536
+ display: flex;
537
+ align-items: center;
538
+ }
539
+
540
+ .file-icon {
541
+ width: 40px;
542
+ height: 40px;
543
+ display: flex;
544
+ align-items: center;
545
+ justify-content: center;
546
+ border-radius: 8px;
547
+ margin-right: 12px;
548
+ font-size: 1.2rem;
549
+ }
550
+
551
+ .file-icon.pdf {
552
+ background: #ff6b6b;
553
+ color: white;
554
+ }
555
+
556
+ .file-icon.image {
557
+ background: #4ecdc4;
558
+ color: white;
559
+ }
560
+
561
+ .file-icon.audio {
562
+ background: #45b7d1;
563
+ color: white;
564
+ }
565
+
566
+ .file-icon.video {
567
+ background: #f9ca24;
568
+ color: white;
569
+ }
570
+
571
+ .file-icon.document {
572
+ background: #6c5ce7;
573
+ color: white;
574
+ }
575
+
576
+ .file-icon.archive {
577
+ background: #fd79a8;
578
+ color: white;
579
+ }
580
+
581
+ .file-icon.default {
582
+ background: #74b9ff;
583
+ color: white;
584
+ }
585
+
586
+ .file-details {
587
+ flex: 1;
588
+ min-width: 0;
589
+ }
590
+
591
+ .file-name {
592
+ font-weight: 500;
593
+ margin-bottom: 2px;
594
+ word-wrap: break-word;
595
+ font-size: 0.9rem;
596
+ }
597
+
598
+ .file-size {
599
+ color: #666;
600
+ font-size: 0.8rem;
601
+ }
602
+
603
+ /* Audio Message Styles */
604
+ .audio-message {
605
+ background: white;
606
+ border: 1px solid #e9ecef;
607
+ border-radius: 18px;
608
+ padding: 8px 12px;
609
+ max-width: 250px;
610
+ display: flex;
611
+ align-items: center;
612
+ }
613
+
614
+ .audio-message.sent {
615
+ background: #dcf8c6;
616
+ border-color: #c5d9a5;
617
+ }
618
+
619
+ .audio-controls {
620
+ display: flex;
621
+ align-items: center;
622
+ }
623
+
624
+ .audio-play-btn {
625
+ background: #25d366;
626
+ border: none;
627
+ border-radius: 50%;
628
+ width: 30px;
629
+ height: 30px;
630
+ display: flex;
631
+ align-items: center;
632
+ justify-content: center;
633
+ color: white;
634
+ cursor: pointer;
635
+ margin-right: 8px;
636
+ }
637
+
638
+ .audio-play-btn:hover {
639
+ background: #128c7e;
640
+ }
641
+
642
+ .audio-duration {
643
+ font-size: 0.8rem;
644
+ color: #666;
645
+ }
646
+
647
+ .audio-waveform {
648
+ width: 80px;
649
+ height: 20px;
650
+ background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 20"><rect x="2" y="8" width="2" height="4" fill="%23666"/><rect x="6" y="6" width="2" height="8" fill="%23666"/><rect x="10" y="10" width="2" height="0" fill="%23666"/><rect x="14" y="7" width="2" height="6" fill="%23666"/><rect x="18" y="9" width="2" height="2" fill="%23666"/><rect x="22" y="5" width="2" height="10" fill="%23666"/><rect x="26" y="8" width="2" height="4" fill="%23666"/><rect x="30" y="6" width="2" height="8" fill="%23666"/><rect x="34" y="9" width="2" height="2" fill="%23666"/><rect x="38" y="7" width="2" height="6" fill="%23666"/><rect x="42" y="8" width="2" height="4" fill="%23666"/><rect x="46" y="5" width="2" height="10" fill="%23666"/><rect x="50" y="9" width="2" height="2" fill="%23666"/><rect x="54" y="6" width="2" height="8" fill="%23666"/><rect x="58" y="8" width="2" height="4" fill="%23666"/><rect x="62" y="7" width="2" height="6" fill="%23666"/><rect x="66" y="9" width="2" height="2" fill="%23666"/><rect x="70" y="6" width="2" height="8" fill="%23666"/><rect x="74" y="8" width="2" height="4" fill="%23666"/></svg>') no-repeat center;
651
+ margin: 0 8px;
652
+ }
653
+
654
+ /* Image Message Styles */
655
+ .image-message {
656
+ border-radius: 12px;
657
+ overflow: hidden;
658
+ max-width: 200px;
659
+ cursor: pointer;
660
+ }
661
+
662
+ .image-message img {
663
+ width: 100%;
664
+ height: auto;
665
+ display: block;
666
+ }
667
+
668
+ /* Audio Recording UI */
669
+ .audio-recording-ui {
670
+ margin-bottom: 0.75rem;
671
+ }
672
+
673
+ .recording-indicator {
674
+ animation: pulse 1.5s ease-in-out infinite alternate;
675
+ }
676
+
677
+ @keyframes pulse {
678
+ from { opacity: 0.5; }
679
+ to { opacity: 1; }
680
+ }
681
+
682
+ /* Avatar Styles */
683
+ .avatar {
684
+ width: 40px;
685
+ height: 40px;
686
+ border-radius: 50%;
687
+ display: flex;
688
+ align-items: center;
689
+ justify-content: center;
690
+ color: white;
691
+ font-weight: bold;
692
+ font-size: 1rem;
693
+ }
694
+
695
+ .avatar-large {
696
+ width: 80px;
697
+ height: 80px;
698
+ border-radius: 50%;
699
+ display: flex;
700
+ align-items: center;
701
+ justify-content: center;
702
+ color: white;
703
+ font-weight: bold;
704
+ font-size: 2rem;
705
+ }
706
+
707
+ .online-indicator {
708
+ width: 12px;
709
+ height: 12px;
710
+ background: #25d366;
711
+ border: 2px solid white;
712
+ border-radius: 50%;
713
+ position: absolute;
714
+ bottom: 0;
715
+ right: 0;
716
+ }
717
+
718
+ /* Settings Page */
719
+ .settings-page {
720
+ background: #f0f2f5;
721
+ min-height: 100vh;
722
+ padding: 2rem 0;
723
+ }
724
+
725
+ .settings-section {
726
+ margin-bottom: 2rem;
727
+ }
728
+
729
+ .setting-item {
730
+ padding: 1rem 0;
731
+ border-bottom: 1px solid #e9ecef;
732
+ }
733
+
734
+ .setting-item:last-child {
735
+ border-bottom: none;
736
+ }
737
+
738
+ /* File Upload Progress */
739
+ .upload-progress {
740
+ background: rgba(255, 255, 255, 0.9);
741
+ border-radius: 12px;
742
+ padding: 12px;
743
+ margin-bottom: 8px;
744
+ border: 1px solid #e9ecef;
745
+ }
746
+
747
+ .progress-bar-custom {
748
+ height: 4px;
749
+ background: #25d366;
750
+ border-radius: 2px;
751
+ transition: width 0.3s ease;
752
+ }
753
+
754
+ /* Responsive Design */
755
+ @media (max-width: 768px) {
756
+ body {
757
+ overflow-y: auto;
758
+ overflow-x: hidden;
759
+ }
760
+
761
+ .h-100 {
762
+ min-height: 100vh;
763
+ height: auto;
764
+ }
765
+
766
+ /* Mobile Chat Layout */
767
+ .chat-page .row {
768
+ height: 100vh;
769
+ }
770
+
771
+ .sidebar {
772
+ position: fixed;
773
+ top: 0;
774
+ left: -100%;
775
+ width: 85%;
776
+ max-width: 320px;
777
+ z-index: 1050;
778
+ transition: left 0.3s ease;
779
+ box-shadow: 0 0 20px rgba(0,0,0,0.3);
780
+ }
781
+
782
+ .sidebar.show {
783
+ left: 0;
784
+ }
785
+
786
+ .sidebar-overlay {
787
+ position: fixed;
788
+ top: 0;
789
+ left: 0;
790
+ width: 100%;
791
+ height: 100%;
792
+ background: rgba(0,0,0,0.5);
793
+ z-index: 1040;
794
+ display: none;
795
+ }
796
+
797
+ .sidebar-overlay.show {
798
+ display: block;
799
+ }
800
+
801
+ .chat-area {
802
+ width: 100%;
803
+ position: relative;
804
+ }
805
+
806
+ .mobile-header {
807
+ display: flex;
808
+ align-items: center;
809
+ justify-content: space-between;
810
+ background: #25d366;
811
+ color: white;
812
+ padding: 0.75rem 1rem;
813
+ position: sticky;
814
+ top: 0;
815
+ z-index: 100;
816
+ box-shadow: 0 2px 4px rgba(0,0,0,0.1);
817
+ }
818
+
819
+ .mobile-menu-btn {
820
+ background: none;
821
+ border: none;
822
+ color: white;
823
+ font-size: 1.2rem;
824
+ cursor: pointer;
825
+ }
826
+
827
+ /* Landing Page Mobile */
828
+ .phone-mockup {
829
+ display: none;
830
+ }
831
+
832
+ .landing-content {
833
+ padding: 2rem 1rem;
834
+ }
835
+
836
+ .feature-item {
837
+ font-size: 1rem;
838
+ margin-bottom: 1rem;
839
+ }
840
+
841
+ /* Register Page Mobile */
842
+ .register-card {
843
+ padding: 1.5rem;
844
+ margin: 1rem;
845
+ border-radius: 10px;
846
+ }
847
+
848
+ /* Chat Messages Mobile */
849
+ .message-content {
850
+ max-width: 85%;
851
+ }
852
+
853
+ .file-message {
854
+ max-width: 250px;
855
+ }
856
+
857
+ .audio-message {
858
+ max-width: 200px;
859
+ }
860
+
861
+ .image-message {
862
+ max-width: 180px;
863
+ }
864
+
865
+ .chat-input-toolbar {
866
+ flex-wrap: wrap;
867
+ gap: 0.5rem;
868
+ }
869
+ }
870
+
871
+ /* Tablet adjustments */
872
+ @media (min-width: 769px) and (max-width: 991px) {
873
+ .file-message {
874
+ max-width: 350px;
875
+ }
876
+
877
+ .message-content {
878
+ max-width: 75%;
879
+ }
880
+ }
881
+
882
+ /* Large screen adjustments */
883
+ @media (min-width: 1200px) {
884
+ .file-message {
885
+ max-width: 400px;
886
+ }
887
+
888
+ .message-content {
889
+ max-width: 65%;
890
+ }
891
+ }
892
+
893
+ /* Additional file type icons */
894
+ .file-icon.apk {
895
+ background: #a4c639;
896
+ color: white;
897
+ }
898
+
899
+ .file-icon.exe {
900
+ background: #0078d4;
901
+ color: white;
902
+ }
903
+
904
+ .file-icon.zip {
905
+ background: #fd79a8;
906
+ color: white;
907
+ }
908
+
909
+ /* Hover effects for interactive elements */
910
+ .message-item:hover .file-message {
911
+ transform: translateY(-1px);
912
+ box-shadow: 0 2px 8px rgba(0,0,0,0.15);
913
+ }
914
+
915
+ .audio-play-btn:active {
916
+ transform: scale(0.95);
917
+ }
918
+
919
+ /* Loading states */
920
+ .loading-spinner {
921
+ display: inline-block;
922
+ width: 16px;
923
+ height: 16px;
924
+ border: 2px solid #ccc;
925
+ border-radius: 50%;
926
+ border-top-color: #25d366;
927
+ animation: spin 1s ease-in-out infinite;
928
+ }
929
+
930
+ @keyframes spin {
931
+ to { transform: rotate(360deg); }
932
+ }
933
+
934
+ /* Error states */
935
+ .error-message {
936
+ color: #dc3545;
937
+ font-size: 0.8rem;
938
+ margin-top: 4px;
939
+ }
940
+
941
+ /* Success states */
942
+ .success-message {
943
+ color: #28a745;
944
+ font-size: 0.8rem;
945
+ margin-top: 4px;
946
+ }
static/images/logo.svg ADDED
static/js/audio.js ADDED
@@ -0,0 +1,414 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Audio recording and playback functionality using Web Audio API
2
+
3
+ let mediaRecorder = null;
4
+ let audioChunks = [];
5
+ let recordingStream = null;
6
+ let recordingStartTime = null;
7
+ let recordingTimer = null;
8
+ let isRecording = false;
9
+
10
+ // Initialize audio recording functionality
11
+ document.addEventListener('DOMContentLoaded', () => {
12
+ if (document.body.classList.contains('chat-page')) {
13
+ initializeAudioRecording();
14
+ }
15
+ });
16
+
17
+ async function initializeAudioRecording() {
18
+ try {
19
+ // Check if getUserMedia is supported
20
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
21
+ console.warn('getUserMedia not supported');
22
+ return;
23
+ }
24
+
25
+ console.log('Audio recording initialized');
26
+ } catch (error) {
27
+ console.error('Error initializing audio recording:', error);
28
+ }
29
+ }
30
+
31
+ async function toggleAudioRecording() {
32
+ if (isRecording) {
33
+ stopAudioRecording();
34
+ } else {
35
+ startAudioRecording();
36
+ }
37
+ }
38
+
39
+ async function startAudioRecording() {
40
+ if (!window.currentConversation) {
41
+ MainJS.showError('Please select a conversation first');
42
+ return;
43
+ }
44
+
45
+ try {
46
+ // Request microphone permission
47
+ recordingStream = await navigator.mediaDevices.getUserMedia({
48
+ audio: {
49
+ echoCancellation: true,
50
+ noiseSuppression: true,
51
+ sampleRate: 44100
52
+ }
53
+ });
54
+
55
+ // Create MediaRecorder
56
+ const options = {
57
+ mimeType: 'audio/webm;codecs=opus'
58
+ };
59
+
60
+ // Fallback to other formats if webm is not supported
61
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
62
+ options.mimeType = 'audio/webm';
63
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
64
+ options.mimeType = 'audio/mp4';
65
+ if (!MediaRecorder.isTypeSupported(options.mimeType)) {
66
+ options.mimeType = 'audio/wav';
67
+ }
68
+ }
69
+ }
70
+
71
+ mediaRecorder = new MediaRecorder(recordingStream, options);
72
+ audioChunks = [];
73
+
74
+ // Set up event handlers
75
+ mediaRecorder.ondataavailable = (event) => {
76
+ if (event.data.size > 0) {
77
+ audioChunks.push(event.data);
78
+ }
79
+ };
80
+
81
+ mediaRecorder.onstop = () => {
82
+ handleRecordingStop();
83
+ };
84
+
85
+ mediaRecorder.onerror = (event) => {
86
+ console.error('MediaRecorder error:', event.error);
87
+ MainJS.showError('Recording failed: ' + event.error.message);
88
+ resetRecordingUI();
89
+ };
90
+
91
+ // Start recording
92
+ mediaRecorder.start(100); // Collect data every 100ms
93
+ isRecording = true;
94
+ recordingStartTime = Date.now();
95
+
96
+ // Update UI
97
+ updateRecordingUI(true);
98
+
99
+ // Start timer
100
+ startRecordingTimer();
101
+
102
+ console.log('Audio recording started');
103
+
104
+ } catch (error) {
105
+ console.error('Error starting audio recording:', error);
106
+
107
+ if (error.name === 'NotAllowedError') {
108
+ MainJS.showError('Microphone access denied. Please allow microphone access to record voice messages.');
109
+ } else if (error.name === 'NotFoundError') {
110
+ MainJS.showError('No microphone found. Please connect a microphone and try again.');
111
+ } else {
112
+ MainJS.showError('Failed to start recording: ' + error.message);
113
+ }
114
+
115
+ resetRecordingUI();
116
+ }
117
+ }
118
+
119
+ function stopAudioRecording() {
120
+ if (!isRecording || !mediaRecorder) {
121
+ return;
122
+ }
123
+
124
+ try {
125
+ mediaRecorder.stop();
126
+ isRecording = false;
127
+
128
+ // Stop all tracks
129
+ if (recordingStream) {
130
+ recordingStream.getTracks().forEach(track => track.stop());
131
+ recordingStream = null;
132
+ }
133
+
134
+ // Stop timer
135
+ if (recordingTimer) {
136
+ clearInterval(recordingTimer);
137
+ recordingTimer = null;
138
+ }
139
+
140
+ console.log('Audio recording stopped');
141
+
142
+ } catch (error) {
143
+ console.error('Error stopping audio recording:', error);
144
+ MainJS.showError('Failed to stop recording');
145
+ resetRecordingUI();
146
+ }
147
+ }
148
+
149
+ function cancelAudioRecording() {
150
+ if (isRecording && mediaRecorder) {
151
+ mediaRecorder.stop();
152
+ isRecording = false;
153
+
154
+ // Stop all tracks
155
+ if (recordingStream) {
156
+ recordingStream.getTracks().forEach(track => track.stop());
157
+ recordingStream = null;
158
+ }
159
+
160
+ // Stop timer
161
+ if (recordingTimer) {
162
+ clearInterval(recordingTimer);
163
+ recordingTimer = null;
164
+ }
165
+
166
+ // Clear chunks
167
+ audioChunks = [];
168
+
169
+ // Reset UI
170
+ resetRecordingUI();
171
+
172
+ console.log('Audio recording cancelled');
173
+ }
174
+ }
175
+
176
+ async function handleRecordingStop() {
177
+ if (audioChunks.length === 0) {
178
+ console.warn('No audio data recorded');
179
+ resetRecordingUI();
180
+ return;
181
+ }
182
+
183
+ try {
184
+ // Create blob from chunks
185
+ const audioBlob = new Blob(audioChunks, { type: 'audio/webm' });
186
+ const duration = (Date.now() - recordingStartTime) / 1000; // Duration in seconds
187
+
188
+ // Validate minimum duration
189
+ if (duration < 0.5) {
190
+ MainJS.showError('Recording too short. Please record for at least 0.5 seconds.');
191
+ resetRecordingUI();
192
+ return;
193
+ }
194
+
195
+ // Validate maximum duration (5 minutes)
196
+ if (duration > 300) {
197
+ MainJS.showError('Recording too long. Maximum duration is 5 minutes.');
198
+ resetRecordingUI();
199
+ return;
200
+ }
201
+
202
+ console.log(`Audio recorded: ${duration.toFixed(2)} seconds, size: ${audioBlob.size} bytes`);
203
+
204
+ // Upload audio
205
+ await uploadAudioMessage(audioBlob, duration);
206
+
207
+ } catch (error) {
208
+ console.error('Error handling recording stop:', error);
209
+ MainJS.showError('Failed to process recording');
210
+ } finally {
211
+ resetRecordingUI();
212
+ }
213
+ }
214
+
215
+ async function uploadAudioMessage(audioBlob, duration) {
216
+ if (!window.currentConversation) {
217
+ MainJS.showError('No conversation selected');
218
+ return;
219
+ }
220
+
221
+ try {
222
+ // Create form data
223
+ const formData = new FormData();
224
+ formData.append('audio', audioBlob, 'voice_message.webm');
225
+ formData.append('conversation_id', window.currentConversation);
226
+ formData.append('duration', duration.toString());
227
+
228
+ // Show uploading indicator
229
+ MainJS.showSuccess('Sending voice message...');
230
+
231
+ // Upload audio
232
+ const response = await fetch('/api/upload_audio', {
233
+ method: 'POST',
234
+ body: formData
235
+ });
236
+
237
+ const result = await response.json();
238
+
239
+ if (result.success) {
240
+ MainJS.showSuccess('Voice message sent!');
241
+
242
+ // Reload messages and conversations
243
+ await loadMessages(window.currentConversation);
244
+ await loadConversations();
245
+ } else {
246
+ MainJS.showError('Failed to send voice message: ' + result.message);
247
+ }
248
+
249
+ } catch (error) {
250
+ console.error('Error uploading audio:', error);
251
+ MainJS.showError('Failed to send voice message');
252
+ }
253
+ }
254
+
255
+ function startRecordingTimer() {
256
+ recordingTimer = setInterval(() => {
257
+ if (!isRecording) return;
258
+
259
+ const elapsed = (Date.now() - recordingStartTime) / 1000;
260
+ const minutes = Math.floor(elapsed / 60);
261
+ const seconds = Math.floor(elapsed % 60);
262
+
263
+ const timeString = `${minutes}:${seconds.toString().padStart(2, '0')}`;
264
+ const timeElement = document.getElementById('recordingTime');
265
+ if (timeElement) {
266
+ timeElement.textContent = timeString;
267
+ }
268
+
269
+ // Auto-stop at 5 minutes
270
+ if (elapsed >= 300) {
271
+ stopAudioRecording();
272
+ }
273
+ }, 100);
274
+ }
275
+
276
+ function updateRecordingUI(recording) {
277
+ const audioButton = document.getElementById('audioButton');
278
+ const audioRecording = document.getElementById('audioRecording');
279
+ const messageForm = document.getElementById('messageForm');
280
+
281
+ if (!audioButton || !audioRecording || !messageForm) return;
282
+
283
+ if (recording) {
284
+ audioButton.innerHTML = '<i class="fas fa-stop text-danger"></i>';
285
+ audioButton.classList.add('btn-danger');
286
+ audioButton.classList.remove('btn-outline-success');
287
+ audioRecording.style.display = 'block';
288
+ messageForm.style.display = 'none';
289
+ } else {
290
+ resetRecordingUI();
291
+ }
292
+ }
293
+
294
+ function resetRecordingUI() {
295
+ const audioButton = document.getElementById('audioButton');
296
+ const audioRecording = document.getElementById('audioRecording');
297
+ const messageForm = document.getElementById('messageForm');
298
+ const recordingTime = document.getElementById('recordingTime');
299
+
300
+ if (audioButton) {
301
+ audioButton.innerHTML = '<i class="fas fa-microphone"></i>';
302
+ audioButton.classList.remove('btn-danger');
303
+ audioButton.classList.add('btn-outline-success');
304
+ }
305
+
306
+ if (audioRecording) {
307
+ audioRecording.style.display = 'none';
308
+ }
309
+
310
+ if (messageForm) {
311
+ messageForm.style.display = 'flex';
312
+ }
313
+
314
+ if (recordingTime) {
315
+ recordingTime.textContent = '00:00';
316
+ }
317
+ }
318
+
319
+ // Audio playback functionality
320
+ const audioElements = new Map();
321
+
322
+ async function playAudioMessage(messageId) {
323
+ try {
324
+ // Stop any currently playing audio
325
+ audioElements.forEach(audio => {
326
+ if (!audio.paused) {
327
+ audio.pause();
328
+ audio.currentTime = 0;
329
+ }
330
+ });
331
+
332
+ // Get or create audio element for this message
333
+ let audio = audioElements.get(messageId);
334
+
335
+ if (!audio) {
336
+ // Fetch audio data
337
+ const response = await fetch(`/api/download/${messageId}`);
338
+ if (!response.ok) {
339
+ throw new Error('Failed to load audio');
340
+ }
341
+
342
+ const blob = await response.blob();
343
+ const audioUrl = URL.createObjectURL(blob);
344
+
345
+ // Create audio element
346
+ audio = new Audio(audioUrl);
347
+ audioElements.set(messageId, audio);
348
+
349
+ // Update play button when audio ends
350
+ audio.addEventListener('ended', () => {
351
+ updateAudioButton(messageId, false);
352
+ URL.revokeObjectURL(audioUrl);
353
+ audioElements.delete(messageId);
354
+ });
355
+
356
+ // Handle errors
357
+ audio.addEventListener('error', (e) => {
358
+ console.error('Audio playback error:', e);
359
+ MainJS.showError('Failed to play audio message');
360
+ updateAudioButton(messageId, false);
361
+ URL.revokeObjectURL(audioUrl);
362
+ audioElements.delete(messageId);
363
+ });
364
+ }
365
+
366
+ // Toggle play/pause
367
+ if (audio.paused) {
368
+ updateAudioButton(messageId, true);
369
+ await audio.play();
370
+ } else {
371
+ audio.pause();
372
+ updateAudioButton(messageId, false);
373
+ }
374
+
375
+ } catch (error) {
376
+ console.error('Error playing audio message:', error);
377
+ MainJS.showError('Failed to play audio message');
378
+ }
379
+ }
380
+
381
+ function updateAudioButton(messageId, playing) {
382
+ const button = document.querySelector(`[onclick*="${messageId}"]`);
383
+ if (button) {
384
+ const icon = button.querySelector('i');
385
+ if (icon) {
386
+ if (playing) {
387
+ icon.className = 'fas fa-pause';
388
+ button.style.background = '#128c7e';
389
+ } else {
390
+ icon.className = 'fas fa-play';
391
+ button.style.background = '#25d366';
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ // Cleanup audio elements on page unload
398
+ window.addEventListener('beforeunload', () => {
399
+ audioElements.forEach(audio => {
400
+ if (!audio.paused) {
401
+ audio.pause();
402
+ }
403
+ // URLs will be automatically revoked when the page unloads
404
+ });
405
+ audioElements.clear();
406
+ });
407
+
408
+ // Export functions for global access
409
+ window.AudioJS = {
410
+ toggleAudioRecording,
411
+ cancelAudioRecording,
412
+ stopAudioRecording,
413
+ playAudioMessage
414
+ };
static/js/chat.js ADDED
@@ -0,0 +1,1016 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Chat functionality
2
+ let conversations = [];
3
+ let messages = {};
4
+ let pollingInterval;
5
+
6
+ // Mobile sidebar functionality
7
+ function toggleMobileSidebar() {
8
+ const sidebar = document.getElementById('sidebar');
9
+ const overlay = document.getElementById('sidebarOverlay');
10
+
11
+ if (sidebar && overlay) {
12
+ sidebar.classList.toggle('show');
13
+ overlay.classList.toggle('show');
14
+
15
+ // Prevent body scroll when sidebar is open
16
+ if (sidebar.classList.contains('show')) {
17
+ document.body.style.overflow = 'hidden';
18
+ } else {
19
+ document.body.style.overflow = '';
20
+ }
21
+ }
22
+ }
23
+
24
+ // Close mobile sidebar when clicking outside
25
+ function closeMobileSidebar() {
26
+ const sidebar = document.getElementById('sidebar');
27
+ const overlay = document.getElementById('sidebarOverlay');
28
+
29
+ if (sidebar && overlay) {
30
+ sidebar.classList.remove('show');
31
+ overlay.classList.remove('show');
32
+ document.body.style.overflow = '';
33
+ }
34
+ }
35
+
36
+ // Initialize chat functionality
37
+ document.addEventListener('DOMContentLoaded', () => {
38
+ if (!document.body.classList.contains('chat-page')) {
39
+ return;
40
+ }
41
+
42
+ initializeChat();
43
+ });
44
+
45
+ async function initializeChat() {
46
+ try {
47
+ console.log('Initializing chat...');
48
+
49
+ // Clear any existing data
50
+ conversations = [];
51
+ messages = {};
52
+ window.currentConversation = null;
53
+
54
+ // Load fresh data
55
+ await loadConversations();
56
+ startPolling();
57
+ setupEventListeners();
58
+
59
+ console.log('Chat initialized successfully');
60
+ } catch (error) {
61
+ console.error('Failed to initialize chat:', error);
62
+ MainJS.showError('Failed to initialize chat');
63
+ }
64
+ }
65
+
66
+ function setupEventListeners() {
67
+ // Message form
68
+ const messageForm = document.getElementById('messageForm');
69
+ if (messageForm) {
70
+ messageForm.addEventListener('submit', handleSendMessage);
71
+ }
72
+
73
+ // Private chat form
74
+ const privateChatForm = document.getElementById('privateChatForm');
75
+ if (privateChatForm) {
76
+ privateChatForm.addEventListener('submit', handleStartPrivateChat);
77
+ }
78
+
79
+ // Group chat form
80
+ const groupChatForm = document.getElementById('groupChatForm');
81
+ if (groupChatForm) {
82
+ groupChatForm.addEventListener('submit', handleCreateGroup);
83
+ }
84
+ }
85
+
86
+ async function loadConversations() {
87
+ try {
88
+ const response = await MainJS.apiRequest('/api/conversations');
89
+
90
+ if (response.success) {
91
+ conversations = response.conversations || [];
92
+ renderConversations();
93
+ } else {
94
+ console.warn('Failed to load conversations:', response.message);
95
+ // Show empty state instead of error for unauthenticated users
96
+ conversations = [];
97
+ renderConversations();
98
+ }
99
+ } catch (error) {
100
+ console.error('Failed to load conversations:', error);
101
+ conversations = [];
102
+ renderConversations();
103
+ }
104
+ }
105
+
106
+ function renderConversations() {
107
+ const conversationsList = document.getElementById('conversationsList');
108
+ if (!conversationsList) {
109
+ console.error('Conversations list element not found');
110
+ return;
111
+ }
112
+
113
+ console.log('Rendering conversations:', conversations);
114
+
115
+ // FORCE CLEAR the conversations list first
116
+ conversationsList.innerHTML = '';
117
+
118
+ if (conversations.length === 0) {
119
+ conversationsList.innerHTML = `
120
+ <div class="text-center p-4 text-muted">
121
+ <i class="fas fa-comments mb-3" style="font-size: 2rem;"></i>
122
+ <p>No conversations yet</p>
123
+ <small>Start a new chat to begin messaging</small>
124
+ </div>
125
+ `;
126
+ console.log('No conversations to display');
127
+ return;
128
+ }
129
+
130
+ conversationsList.innerHTML = conversations.map(conv => {
131
+ const lastMessage = conv.last_message;
132
+ const isActive = window.currentConversation === conv.id;
133
+
134
+ return `
135
+ <div class="conversation-item ${isActive ? 'active' : ''}" onclick="selectConversation('${conv.id}')">
136
+ <div class="d-flex align-items-center">
137
+ <div class="avatar bg-success me-3 position-relative">
138
+ ${conv.type === 'group' ? '<i class="fas fa-users"></i>' : conv.name[0].toUpperCase()}
139
+ ${conv.type === 'private' && conv.online ? '<div class="online-indicator"></div>' : ''}
140
+ </div>
141
+ <div class="flex-grow-1">
142
+ <div class="d-flex justify-content-between align-items-start">
143
+ <div class="fw-bold">${MainJS.escapeHtml(conv.name)}</div>
144
+ ${lastMessage ? `<small class="text-muted">${MainJS.formatTime(lastMessage.timestamp)}</small>` : ''}
145
+ </div>
146
+ ${lastMessage ? `
147
+ <div class="text-muted small text-truncate">
148
+ ${conv.type === 'group' ? `${MainJS.escapeHtml(lastMessage.sender_name)}: ` : ''}
149
+ ${MainJS.escapeHtml(lastMessage.content)}
150
+ </div>
151
+ ` : '<div class="text-muted small">No messages yet</div>'}
152
+ ${conv.type === 'private' && !conv.online ? '<div class="text-offline small">offline</div>' : ''}
153
+ </div>
154
+ </div>
155
+ </div>
156
+ `;
157
+ }).join('');
158
+ }
159
+
160
+ async function selectConversation(conversationId) {
161
+ try {
162
+ console.log('Selecting conversation:', conversationId);
163
+
164
+ // Validate that conversation exists
165
+ const conversation = conversations.find(c => c.id === conversationId);
166
+ if (!conversation) {
167
+ console.error('Conversation not found:', conversationId);
168
+ MainJS.showError('Conversation not found. Please refresh and try again.');
169
+ return;
170
+ }
171
+
172
+ window.currentConversation = conversationId;
173
+
174
+ // Update UI
175
+ document.querySelectorAll('.conversation-item').forEach(item => {
176
+ item.classList.remove('active');
177
+ });
178
+
179
+ // Find and activate the clicked conversation
180
+ const clickedItem = document.querySelector(`[onclick*="${conversationId}"]`);
181
+ if (clickedItem) {
182
+ clickedItem.classList.add('active');
183
+ }
184
+
185
+ // Show chat container
186
+ const welcomeScreen = document.getElementById('welcomeScreen');
187
+ const chatContainer = document.getElementById('chatContainer');
188
+
189
+ if (welcomeScreen) welcomeScreen.style.display = 'none';
190
+ if (chatContainer) {
191
+ chatContainer.style.display = 'flex';
192
+ console.log('Chat container displayed');
193
+ } else {
194
+ console.error('Chat container not found');
195
+ }
196
+
197
+ // Close mobile sidebar when conversation is selected
198
+ if (window.innerWidth < 768) {
199
+ closeMobileSidebar();
200
+ }
201
+
202
+ // Update chat header first
203
+ updateChatHeader(conversationId);
204
+
205
+ // Load conversation details with local storage for instant loading
206
+ console.log('Loading messages for conversation:', conversationId);
207
+ await loadMessagesWithLocalStorage(conversationId);
208
+
209
+ // Mark messages as seen
210
+ markMessagesAsSeen(conversationId);
211
+
212
+ console.log('Conversation selected successfully');
213
+ } catch (error) {
214
+ console.error('Error selecting conversation:', error);
215
+ MainJS.showError('Failed to load conversation');
216
+ }
217
+ }
218
+
219
+ async function loadMessages(conversationId) {
220
+ try {
221
+ console.log('Loading messages for conversation ID:', conversationId);
222
+ const response = await MainJS.apiRequest(`/api/messages/${conversationId}`);
223
+ console.log('Messages API response:', response);
224
+
225
+ if (response.success) {
226
+ messages[conversationId] = response.messages || [];
227
+ console.log('Messages loaded:', response.messages.length);
228
+
229
+ // Save messages to local storage
230
+ saveMessagesToLocalStorage(conversationId, messages[conversationId]);
231
+
232
+ renderMessages(conversationId);
233
+ } else {
234
+ console.error('API error:', response.message);
235
+ // Even if API fails, show the chat interface with empty state
236
+ messages[conversationId] = [];
237
+ renderMessages(conversationId);
238
+ MainJS.showError('Failed to load messages: ' + response.message);
239
+ }
240
+ } catch (error) {
241
+ console.error('Failed to load messages:', error);
242
+ // Show empty chat interface even on error
243
+ messages[conversationId] = [];
244
+ renderMessages(conversationId);
245
+ MainJS.showError('Connection error while loading messages');
246
+ }
247
+ }
248
+
249
+ function renderMessages(conversationId) {
250
+ console.log('Rendering messages for conversation:', conversationId);
251
+ const chatMessages = document.getElementById('chatMessages');
252
+ console.log('Chat messages element:', chatMessages);
253
+ console.log('Messages data:', messages[conversationId]);
254
+
255
+ if (!chatMessages) {
256
+ console.error('Chat messages element not found!');
257
+ return;
258
+ }
259
+
260
+ if (!messages[conversationId]) {
261
+ console.error('No messages found for conversation:', conversationId);
262
+ return;
263
+ }
264
+
265
+ const conversationMessages = messages[conversationId];
266
+ console.log('Number of messages to render:', conversationMessages.length);
267
+
268
+ if (conversationMessages.length === 0) {
269
+ console.log('No messages, showing empty state');
270
+ chatMessages.innerHTML = `
271
+ <div class="text-center text-muted">
272
+ <i class="fas fa-comment-dots mb-2" style="font-size: 2rem;"></i>
273
+ <p>No messages yet</p>
274
+ <small>Send the first message to start the conversation</small>
275
+ </div>
276
+ `;
277
+ return;
278
+ }
279
+
280
+ chatMessages.innerHTML = conversationMessages.map(msg => {
281
+ const isCurrentUser = msg.sender_id === getCurrentUserId();
282
+ const messageClass = isCurrentUser ? 'sent' : 'received';
283
+
284
+ // Render different message types
285
+ if (msg.message_type === 'image') {
286
+ return renderImageMessage(msg, messageClass);
287
+ } else if (msg.message_type === 'file') {
288
+ return renderFileMessage(msg, messageClass);
289
+ } else if (msg.message_type === 'audio') {
290
+ return renderAudioMessage(msg, messageClass);
291
+ } else {
292
+ return renderTextMessage(msg, messageClass);
293
+ }
294
+ }).join('');
295
+
296
+ // Scroll to bottom
297
+ chatMessages.scrollTop = chatMessages.scrollHeight;
298
+
299
+ // Mark messages as seen when viewing conversation
300
+ setTimeout(() => markVisibleMessagesAsSeen(), 500);
301
+ }
302
+
303
+ function renderTextMessage(msg, messageClass) {
304
+ const isCurrentUser = messageClass === 'sent';
305
+ return `
306
+ <div class="message-item ${messageClass}">
307
+ <div class="message-content">
308
+ ${!isCurrentUser && getConversationType(window.currentConversation) === 'group' ?
309
+ `<div class="small text-muted mb-1">${MainJS.escapeHtml(msg.sender_name)}</div>` : ''}
310
+ <div class="message-bubble">
311
+ ${MainJS.escapeHtml(msg.content)}
312
+ </div>
313
+ <div class="message-time">
314
+ ${MainJS.formatMessageTime(msg.timestamp)}
315
+ ${isCurrentUser ? getMessageStatusIcon(msg) : ''}
316
+ </div>
317
+ </div>
318
+ </div>
319
+ `;
320
+ }
321
+
322
+ function renderImageMessage(msg, messageClass) {
323
+ const isCurrentUser = messageClass === 'sent';
324
+ return `
325
+ <div class="message-item ${messageClass}">
326
+ <div class="message-content">
327
+ ${!isCurrentUser && getConversationType(window.currentConversation) === 'group' ?
328
+ `<div class="small text-muted mb-1">${MainJS.escapeHtml(msg.sender_name)}</div>` : ''}
329
+ <div class="image-message ${messageClass}" onclick="openImagePreview('${msg.id}')">
330
+ <img src="/api/image/${msg.id}" alt="${MainJS.escapeHtml(msg.file_name || 'Image')}"
331
+ class="message-image" loading="lazy">
332
+ <div class="image-overlay">
333
+ <i class="fas fa-search-plus"></i>
334
+ </div>
335
+ </div>
336
+ <div class="message-time">
337
+ ${MainJS.formatMessageTime(msg.timestamp)}
338
+ ${isCurrentUser ? getMessageStatusIcon(msg) : ''}
339
+ </div>
340
+ </div>
341
+ </div>
342
+ `;
343
+ }
344
+
345
+ function renderFileMessage(msg, messageClass) {
346
+ const isCurrentUser = messageClass === 'sent';
347
+ const iconClass = MainJS.getFileIconClass(msg.file_type || '');
348
+ const iconColor = MainJS.getFileIconColor(msg.file_type || '');
349
+
350
+ return `
351
+ <div class="message-item ${messageClass}">
352
+ <div class="message-content">
353
+ ${!isCurrentUser && getConversationType(window.currentConversation) === 'group' ?
354
+ `<div class="small text-muted mb-1">${MainJS.escapeHtml(msg.sender_name)}</div>` : ''}
355
+ <div class="file-message ${messageClass}" onclick="downloadFile('${msg.id}')">
356
+ <div class="file-info">
357
+ <div class="file-icon ${iconColor}">
358
+ <i class="${iconClass}"></i>
359
+ </div>
360
+ <div class="file-details">
361
+ <div class="file-name">${MainJS.escapeHtml(msg.file_name || 'Unknown File')}</div>
362
+ <div class="file-size">${msg.file_size_formatted || '0B'}</div>
363
+ </div>
364
+ </div>
365
+ </div>
366
+ <div class="message-time">
367
+ ${MainJS.formatMessageTime(msg.timestamp)}
368
+ ${isCurrentUser ? getMessageStatusIcon(msg) : ''}
369
+ </div>
370
+ </div>
371
+ </div>
372
+ `;
373
+ }
374
+
375
+ function renderAudioMessage(msg, messageClass) {
376
+ const isCurrentUser = messageClass === 'sent';
377
+ const duration = msg.audio_duration ? Math.floor(msg.audio_duration) : 0;
378
+ const minutes = Math.floor(duration / 60);
379
+ const seconds = duration % 60;
380
+ const durationText = `${minutes}:${seconds.toString().padStart(2, '0')}`;
381
+
382
+ return `
383
+ <div class="message-item ${messageClass}">
384
+ <div class="message-content">
385
+ ${!isCurrentUser && getConversationType(window.currentConversation) === 'group' ?
386
+ `<div class="small text-muted mb-1">${MainJS.escapeHtml(msg.sender_name)}</div>` : ''}
387
+ <div class="audio-message ${messageClass}">
388
+ <div class="audio-controls">
389
+ <button class="audio-play-btn" onclick="playAudioMessage('${msg.id}')">
390
+ <i class="fas fa-play"></i>
391
+ </button>
392
+ <div class="audio-waveform"></div>
393
+ <div class="audio-duration">${durationText}</div>
394
+ </div>
395
+ </div>
396
+ <div class="message-time">
397
+ ${MainJS.formatMessageTime(msg.timestamp)}
398
+ ${isCurrentUser ? getMessageStatusIcon(msg) : ''}
399
+ </div>
400
+ </div>
401
+ </div>
402
+ `;
403
+ }
404
+
405
+ function getMessageStatusIcon(message) {
406
+ // FIXED: Proper blue tick logic
407
+ // Blue ticks ONLY when recipient is ONLINE AND has actually seen the message
408
+ // Gray ticks when delivered but not seen OR when recipient is offline
409
+
410
+ const seenCount = message.seen_by ? message.seen_by.length : 0;
411
+ const currentUserId = document.querySelector('[data-user-id]')?.getAttribute('data-user-id');
412
+
413
+ // For group chats, check if any non-sender has seen it while online
414
+ const conversation = conversations.find(c => c.id === window.currentConversation);
415
+ if (!conversation) {
416
+ // Default to single gray tick if can't find conversation
417
+ return '<span class="message-status sent"><i class="fas fa-check"></i></span>';
418
+ }
419
+
420
+ // Check if ANY recipient is currently online AND has seen the message
421
+ const hasOnlineRecipientSeen = conversation.participants.some(participant => {
422
+ return participant.id !== currentUserId && // Not the sender
423
+ participant.online === true && // Currently online
424
+ message.seen_by && message.seen_by.includes(participant.id); // Has seen the message
425
+ });
426
+
427
+ if (hasOnlineRecipientSeen) {
428
+ // Blue double tick: seen by online recipient
429
+ return '<span class="message-status seen"><i class="fas fa-check-double"></i></span>';
430
+ } else if (seenCount > 0) {
431
+ // Gray double tick: seen but recipient was offline or is offline now
432
+ return '<span class="message-status delivered"><i class="fas fa-check-double"></i></span>';
433
+ } else {
434
+ // Single gray tick: delivered but not seen
435
+ return '<span class="message-status sent"><i class="fas fa-check"></i></span>';
436
+ }
437
+ }
438
+
439
+ function updateChatHeader(conversationId) {
440
+ const chatHeader = document.getElementById('chatHeader');
441
+ const conversation = conversations.find(c => c.id === conversationId);
442
+
443
+ if (!chatHeader || !conversation) return;
444
+
445
+ chatHeader.innerHTML = `
446
+ <div class="d-flex align-items-center">
447
+ <div class="avatar bg-success me-3 position-relative">
448
+ ${conversation.type === 'group' ? '<i class="fas fa-users"></i>' : conversation.name[0].toUpperCase()}
449
+ ${conversation.type === 'private' && conversation.online ? '<div class="online-indicator"></div>' : ''}
450
+ </div>
451
+ <div>
452
+ <div class="fw-bold">${MainJS.escapeHtml(conversation.name)}</div>
453
+ <small>
454
+ ${conversation.type === 'group'
455
+ ? `${conversation.participants.length} members`
456
+ : conversation.online ? 'online' : 'offline'
457
+ }
458
+ </small>
459
+ </div>
460
+ </div>
461
+ `;
462
+ }
463
+
464
+ async function handleSendMessage(event) {
465
+ event.preventDefault();
466
+
467
+ const messageInput = document.getElementById('messageInput');
468
+ const content = messageInput.value.trim();
469
+
470
+ if (!content || !window.currentConversation) {
471
+ return;
472
+ }
473
+
474
+ try {
475
+ const response = await MainJS.apiRequest('/api/send_message', {
476
+ method: 'POST',
477
+ body: JSON.stringify({
478
+ conversation_id: window.currentConversation,
479
+ content: content
480
+ })
481
+ });
482
+
483
+ if (response.success) {
484
+ messageInput.value = '';
485
+
486
+ // Add message to local state for instant display (zero delay like your reference code)
487
+ if (!messages[window.currentConversation]) {
488
+ messages[window.currentConversation] = [];
489
+ }
490
+ messages[window.currentConversation].push(response.message);
491
+
492
+ // Save messages to local storage
493
+ saveMessagesToLocalStorage(window.currentConversation, messages[window.currentConversation]);
494
+
495
+ // Render messages instantly for fast response - no setTimeout delays
496
+ requestAnimationFrame(() => {
497
+ renderMessages(window.currentConversation);
498
+ });
499
+
500
+ // Update conversations list
501
+ await loadConversations();
502
+ } else {
503
+ MainJS.showError('Failed to send message: ' + response.message);
504
+ }
505
+ } catch (error) {
506
+ console.error('Error sending message:', error);
507
+ MainJS.showError('Failed to send message');
508
+ }
509
+ }
510
+
511
+ // File download function
512
+ async function downloadFile(messageId) {
513
+ try {
514
+ window.open(`/api/download/${messageId}`, '_blank');
515
+ } catch (error) {
516
+ console.error('Error downloading file:', error);
517
+ MainJS.showError('Failed to download file');
518
+ }
519
+ }
520
+
521
+ // Audio playback function
522
+ async function playAudioMessage(messageId) {
523
+ try {
524
+ const response = await fetch(`/api/download/${messageId}`);
525
+ if (response.ok) {
526
+ const blob = await response.blob();
527
+ const audioUrl = URL.createObjectURL(blob);
528
+ const audio = new Audio(audioUrl);
529
+
530
+ audio.play().catch(error => {
531
+ console.error('Error playing audio:', error);
532
+ MainJS.showError('Failed to play audio');
533
+ });
534
+
535
+ // Clean up URL when audio ends
536
+ audio.addEventListener('ended', () => {
537
+ URL.revokeObjectURL(audioUrl);
538
+ });
539
+ }
540
+ } catch (error) {
541
+ console.error('Error playing audio:', error);
542
+ MainJS.showError('Failed to play audio');
543
+ }
544
+ }
545
+
546
+ // Helper functions
547
+ function getCurrentUserId() {
548
+ return window.currentUserId;
549
+ }
550
+
551
+ function getConversationType(conversationId) {
552
+ const conversation = conversations.find(c => c.id === conversationId);
553
+ return conversation ? conversation.type : 'private';
554
+ }
555
+
556
+ async function markMessagesAsSeen(conversationId) {
557
+ try {
558
+ // Get all message IDs from current conversation
559
+ const conversationMessages = messages[conversationId] || [];
560
+ const messageIds = conversationMessages.map(msg => msg.id);
561
+
562
+ if (messageIds.length > 0) {
563
+ await MainJS.apiRequest('/api/mark_seen', {
564
+ method: 'POST',
565
+ body: JSON.stringify({
566
+ message_ids: messageIds
567
+ })
568
+ });
569
+ }
570
+ } catch (error) {
571
+ console.error('Failed to mark messages as seen:', error);
572
+ }
573
+ }
574
+
575
+ function startPolling() {
576
+ // Poll for new messages every 1 second for instant response like your original code
577
+ pollingInterval = setInterval(async () => {
578
+ try {
579
+ // Reload conversations to get latest messages
580
+ await loadConversations();
581
+
582
+ // If a conversation is selected, reload its messages
583
+ if (window.currentConversation) {
584
+ await loadMessages(window.currentConversation);
585
+ markMessagesAsSeen(window.currentConversation);
586
+ }
587
+ } catch (error) {
588
+ console.error('Polling error:', error);
589
+ }
590
+ }, 1000);
591
+ }
592
+
593
+ // New chat functions
594
+ function startPrivateChat() {
595
+ const newChatModal = bootstrap.Modal.getInstance(document.getElementById('newChatModal'));
596
+ const privateChatModal = new bootstrap.Modal(document.getElementById('privateChatModal'));
597
+
598
+ newChatModal.hide();
599
+ privateChatModal.show();
600
+ }
601
+
602
+ function startGroupChat() {
603
+ const newChatModal = bootstrap.Modal.getInstance(document.getElementById('newChatModal'));
604
+ const groupChatModal = new bootstrap.Modal(document.getElementById('groupChatModal'));
605
+
606
+ newChatModal.hide();
607
+ groupChatModal.show();
608
+ }
609
+
610
+ async function findUser() {
611
+ const userIdInput = document.getElementById('userIdInput');
612
+ const uniqueId = userIdInput.value.trim().toUpperCase();
613
+
614
+ if (!uniqueId) {
615
+ MainJS.showError('Please enter a user ID');
616
+ return;
617
+ }
618
+
619
+ try {
620
+ const response = await MainJS.apiRequest('/api/find_user', {
621
+ method: 'POST',
622
+ body: JSON.stringify({ unique_id: uniqueId })
623
+ });
624
+
625
+ const userPreview = document.getElementById('userPreview');
626
+ const startChatBtn = document.getElementById('startChatBtn');
627
+
628
+ if (response.success) {
629
+ userPreview.innerHTML = `
630
+ <div class="d-flex align-items-center">
631
+ <div class="avatar bg-success me-3">${response.user.name[0].toUpperCase()}</div>
632
+ <div>
633
+ <div class="fw-bold">${MainJS.escapeHtml(response.user.name)}</div>
634
+ <small class="text-muted">${response.user.unique_id}</small>
635
+ </div>
636
+ </div>
637
+ `;
638
+ userPreview.style.display = 'block';
639
+ startChatBtn.style.display = 'block';
640
+ startChatBtn.dataset.userId = response.user.user_id;
641
+ } else {
642
+ userPreview.innerHTML = `<div class="text-danger">${response.message}</div>`;
643
+ userPreview.style.display = 'block';
644
+ startChatBtn.style.display = 'none';
645
+ }
646
+ } catch (error) {
647
+ console.error('Error finding user:', error);
648
+ MainJS.showError('Failed to find user');
649
+ }
650
+ }
651
+
652
+ async function handleStartPrivateChat(event) {
653
+ event.preventDefault();
654
+
655
+ const startChatBtn = document.getElementById('startChatBtn');
656
+ const userId = startChatBtn.dataset.userId;
657
+
658
+ if (!userId) {
659
+ MainJS.showError('Please find a user first');
660
+ return;
661
+ }
662
+
663
+ try {
664
+ const response = await MainJS.apiRequest('/api/start_private_chat', {
665
+ method: 'POST',
666
+ body: JSON.stringify({ user_id: userId })
667
+ });
668
+
669
+ if (response.success) {
670
+ const privateChatModal = bootstrap.Modal.getInstance(document.getElementById('privateChatModal'));
671
+ privateChatModal.hide();
672
+
673
+ // Refresh conversations and select the new one
674
+ await loadConversations();
675
+ await selectConversation(response.conversation_id);
676
+ } else {
677
+ MainJS.showError('Failed to start chat: ' + response.message);
678
+ }
679
+ } catch (error) {
680
+ console.error('Error starting private chat:', error);
681
+ MainJS.showError('Failed to start chat');
682
+ }
683
+ }
684
+
685
+ async function handleCreateGroup(event) {
686
+ event.preventDefault();
687
+
688
+ const groupName = document.getElementById('groupNameInput').value.trim();
689
+ const memberInputs = document.querySelectorAll('.member-input');
690
+ const members = Array.from(memberInputs)
691
+ .map(input => input.value.trim().toUpperCase())
692
+ .filter(value => value);
693
+
694
+ if (!groupName) {
695
+ MainJS.showError('Please enter a group name');
696
+ return;
697
+ }
698
+
699
+ if (members.length === 0) {
700
+ MainJS.showError('Please add at least one member');
701
+ return;
702
+ }
703
+
704
+ try {
705
+ const response = await MainJS.apiRequest('/api/create_group', {
706
+ method: 'POST',
707
+ body: JSON.stringify({
708
+ name: groupName,
709
+ members: members
710
+ })
711
+ });
712
+
713
+ if (response.success) {
714
+ const groupChatModal = bootstrap.Modal.getInstance(document.getElementById('groupChatModal'));
715
+ groupChatModal.hide();
716
+
717
+ // Reset form
718
+ document.getElementById('groupChatForm').reset();
719
+
720
+ // Refresh conversations and select the new one
721
+ await loadConversations();
722
+ await selectConversation(response.conversation_id);
723
+ } else {
724
+ MainJS.showError('Failed to create group: ' + response.message);
725
+ }
726
+ } catch (error) {
727
+ console.error('Error creating group:', error);
728
+ MainJS.showError('Failed to create group');
729
+ }
730
+ }
731
+
732
+ function addMemberField() {
733
+ const groupMembers = document.getElementById('groupMembers');
734
+ const memberCount = groupMembers.querySelectorAll('.member-input').length;
735
+
736
+ if (memberCount >= 9) {
737
+ MainJS.showError('Maximum 9 members allowed');
738
+ return;
739
+ }
740
+
741
+ const memberField = document.createElement('div');
742
+ memberField.className = 'input-group mb-2';
743
+ memberField.innerHTML = `
744
+ <input type="text" class="form-control member-input" placeholder="Enter user ID">
745
+ <button type="button" class="btn btn-outline-danger" onclick="removeMemberField(this)">
746
+ <i class="fas fa-minus"></i>
747
+ </button>
748
+ `;
749
+
750
+ groupMembers.appendChild(memberField);
751
+ }
752
+
753
+ function removeMemberField(button) {
754
+ button.parentElement.remove();
755
+ }
756
+
757
+ // Image preview functionality (WhatsApp-like) - SIMPLIFIED TO PREVENT TOUCH ISSUES
758
+ function openImagePreview(messageId) {
759
+ // Remove any existing image preview first
760
+ const existingModal = document.querySelector('.image-preview-modal');
761
+ if (existingModal) {
762
+ existingModal.remove();
763
+ }
764
+
765
+ const imageUrl = `/api/image/${messageId}`;
766
+
767
+ // Create simple modal with minimal interference
768
+ const modal = document.createElement('div');
769
+ modal.className = 'image-preview-modal';
770
+ modal.innerHTML = `
771
+ <div class="image-preview-overlay">
772
+ <div class="image-preview-container">
773
+ <button class="image-preview-close" onclick="closeImagePreview()">
774
+ <i class="fas fa-times"></i>
775
+ </button>
776
+ <img src="${imageUrl}" alt="Image Preview" class="image-preview-image">
777
+ <div class="image-preview-actions">
778
+ <a href="/api/download/${messageId}" target="_blank" class="btn btn-outline-light">
779
+ <i class="fas fa-download"></i> Download
780
+ </a>
781
+ </div>
782
+ </div>
783
+ </div>
784
+ `;
785
+
786
+ // Simple modal without any complex touch handling
787
+ document.body.appendChild(modal);
788
+
789
+ // Add click to close functionality
790
+ modal.querySelector('.image-preview-overlay').addEventListener('click', (e) => {
791
+ if (e.target === e.currentTarget) {
792
+ closeImagePreview();
793
+ }
794
+ });
795
+
796
+ // Mark message as seen when viewing image
797
+ markMessageAsSeen(messageId);
798
+ }
799
+
800
+ function closeImagePreview() {
801
+ // Simple close function - just remove modal, no complex touch handling
802
+ const modal = document.querySelector('.image-preview-modal');
803
+ if (modal) {
804
+ modal.remove();
805
+ console.log('Image preview closed');
806
+ }
807
+ }
808
+
809
+ // Touch event prevention function for modal
810
+ function preventTouch(e) {
811
+ e.preventDefault();
812
+ }
813
+
814
+ // Double blue tick system - Mark messages as seen
815
+ async function markMessageAsSeen(messageId) {
816
+ try {
817
+ await MainJS.apiRequest('/api/mark_seen', {
818
+ method: 'POST',
819
+ body: JSON.stringify({ message_ids: [messageId] })
820
+ });
821
+ } catch (error) {
822
+ console.error('Error marking message as seen:', error);
823
+ }
824
+ }
825
+
826
+ // Mark all visible messages as seen
827
+ async function markVisibleMessagesAsSeen() {
828
+ if (!window.currentConversation) return;
829
+
830
+ const conversationMessages = messages[window.currentConversation] || [];
831
+ const messageIds = conversationMessages
832
+ .filter(msg => msg.sender_id !== getCurrentUserId()) // Only mark messages from others
833
+ .map(msg => msg.id);
834
+
835
+ if (messageIds.length > 0) {
836
+ try {
837
+ await MainJS.apiRequest('/api/mark_seen', {
838
+ method: 'POST',
839
+ body: JSON.stringify({ message_ids: messageIds })
840
+ });
841
+ } catch (error) {
842
+ console.error('Error marking messages as seen:', error);
843
+ }
844
+ }
845
+ }
846
+
847
+ // Update message status (for double blue tick display)
848
+ async function updateMessageStatuses() {
849
+ if (!window.currentConversation) return;
850
+
851
+ const conversationMessages = messages[window.currentConversation] || [];
852
+ const currentUserId = getCurrentUserId();
853
+
854
+ // Only check status for messages sent by current user
855
+ const sentMessages = conversationMessages.filter(msg => msg.sender_id === currentUserId);
856
+
857
+ for (const message of sentMessages) {
858
+ try {
859
+ const response = await MainJS.apiRequest(`/api/message_status/${message.id}`);
860
+ if (response.success) {
861
+ message.status = response.status;
862
+ }
863
+ } catch (error) {
864
+ console.error('Error updating message status:', error);
865
+ }
866
+ }
867
+ }
868
+
869
+ // Local Storage Functions
870
+ function saveMessagesToLocalStorage(conversationId, messageList) {
871
+ try {
872
+ const storageKey = `chat_messages_${conversationId}`;
873
+ localStorage.setItem(storageKey, JSON.stringify(messageList));
874
+ console.log(`Saved ${messageList.length} messages to local storage for conversation ${conversationId}`);
875
+ } catch (error) {
876
+ console.error('Failed to save messages to local storage:', error);
877
+ }
878
+ }
879
+
880
+ function loadMessagesFromLocalStorage(conversationId) {
881
+ try {
882
+ const storageKey = `chat_messages_${conversationId}`;
883
+ const stored = localStorage.getItem(storageKey);
884
+ const messages = stored ? JSON.parse(stored) : [];
885
+ console.log(`Loaded ${messages.length} messages from local storage for conversation ${conversationId}`);
886
+ return messages;
887
+ } catch (error) {
888
+ console.error('Failed to load messages from local storage:', error);
889
+ return [];
890
+ }
891
+ }
892
+
893
+ function clearLocalStorageForConversation(conversationId) {
894
+ try {
895
+ const storageKey = `chat_messages_${conversationId}`;
896
+ localStorage.removeItem(storageKey);
897
+ } catch (error) {
898
+ console.error('Failed to clear local storage:', error);
899
+ }
900
+ }
901
+
902
+ // Load messages from local storage first, then update from server
903
+ async function loadMessagesWithLocalStorage(conversationId) {
904
+ try {
905
+ console.log('Loading messages with local storage for:', conversationId);
906
+
907
+ // First show cached messages for instant loading
908
+ const cachedMessages = loadMessagesFromLocalStorage(conversationId);
909
+ console.log('Cached messages loaded:', cachedMessages.length);
910
+
911
+ if (cachedMessages.length > 0) {
912
+ messages[conversationId] = cachedMessages;
913
+ renderMessages(conversationId);
914
+ console.log('Rendered cached messages');
915
+ }
916
+
917
+ // Then load fresh messages from server
918
+ console.log('Loading fresh messages from server...');
919
+ await loadMessages(conversationId);
920
+
921
+ } catch (error) {
922
+ console.error('Error in loadMessagesWithLocalStorage:', error);
923
+ // Fallback to regular loading
924
+ await loadMessages(conversationId);
925
+ }
926
+ }
927
+
928
+ // Mobile sidebar functions
929
+ function toggleMobileSidebar() {
930
+ const sidebar = document.getElementById('sidebar');
931
+ const overlay = document.getElementById('sidebarOverlay');
932
+
933
+ if (sidebar && overlay) {
934
+ sidebar.classList.toggle('show');
935
+ overlay.classList.toggle('show');
936
+ }
937
+ }
938
+
939
+ function closeMobileSidebar() {
940
+ const sidebar = document.getElementById('sidebar');
941
+ const overlay = document.getElementById('sidebarOverlay');
942
+
943
+ if (sidebar && overlay) {
944
+ sidebar.classList.remove('show');
945
+ overlay.classList.remove('show');
946
+ }
947
+ }
948
+
949
+ // Chat creation functions
950
+ function startPrivateChat() {
951
+ // Close new chat modal and open private chat modal
952
+ const newChatModal = bootstrap.Modal.getInstance(document.getElementById('newChatModal'));
953
+ const privateChatModal = new bootstrap.Modal(document.getElementById('privateChatModal'));
954
+
955
+ if (newChatModal) newChatModal.hide();
956
+ privateChatModal.show();
957
+ }
958
+
959
+ function startGroupChat() {
960
+ // Close new chat modal and open group chat modal
961
+ const newChatModal = bootstrap.Modal.getInstance(document.getElementById('newChatModal'));
962
+ const groupChatModal = new bootstrap.Modal(document.getElementById('groupChatModal'));
963
+
964
+ if (newChatModal) newChatModal.hide();
965
+ groupChatModal.show();
966
+ }
967
+
968
+ async function findUser() {
969
+ const userIdInput = document.getElementById('userIdInput');
970
+ const userPreview = document.getElementById('userPreview');
971
+ const startChatBtn = document.getElementById('startChatBtn');
972
+
973
+ const userId = userIdInput.value.trim();
974
+ if (!userId) {
975
+ MainJS.showError('Please enter a user ID');
976
+ return;
977
+ }
978
+
979
+ try {
980
+ const response = await MainJS.apiRequest('/api/find-user', {
981
+ method: 'POST',
982
+ body: JSON.stringify({ user_id: userId })
983
+ });
984
+
985
+ if (response.success && response.user) {
986
+ userPreview.innerHTML = `
987
+ <div class="d-flex align-items-center">
988
+ <div class="avatar bg-success me-3">
989
+ ${response.user.name[0].toUpperCase()}
990
+ </div>
991
+ <div>
992
+ <div class="fw-bold">${MainJS.escapeHtml(response.user.name)}</div>
993
+ <small class="text-muted">${MainJS.escapeHtml(response.user.email || 'No email')}</small>
994
+ </div>
995
+ </div>
996
+ `;
997
+ userPreview.style.display = 'block';
998
+ startChatBtn.style.display = 'block';
999
+ window.foundUser = response.user;
1000
+ } else {
1001
+ MainJS.showError('User not found');
1002
+ userPreview.style.display = 'none';
1003
+ startChatBtn.style.display = 'none';
1004
+ }
1005
+ } catch (error) {
1006
+ console.error('Find user error:', error);
1007
+ MainJS.showError('Failed to find user');
1008
+ }
1009
+ }
1010
+
1011
+ // Cleanup on page unload
1012
+ window.addEventListener('beforeunload', () => {
1013
+ if (pollingInterval) {
1014
+ clearInterval(pollingInterval);
1015
+ }
1016
+ });
static/js/files.js ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // File upload and management functionality
2
+
3
+ // Initialize file upload functionality
4
+ document.addEventListener('DOMContentLoaded', () => {
5
+ if (document.body.classList.contains('chat-page')) {
6
+ initializeFileUpload();
7
+ }
8
+ });
9
+
10
+ function initializeFileUpload() {
11
+ const fileInput = document.getElementById('fileInput');
12
+ const imageInput = document.getElementById('imageInput');
13
+
14
+ if (fileInput) {
15
+ fileInput.addEventListener('change', handleFileSelect);
16
+ }
17
+
18
+ if (imageInput) {
19
+ imageInput.addEventListener('change', handleFileSelect);
20
+ }
21
+
22
+ // Handle drag and drop
23
+ const chatMessages = document.getElementById('chatMessages');
24
+ if (chatMessages) {
25
+ chatMessages.addEventListener('dragover', handleDragOver);
26
+ chatMessages.addEventListener('drop', handleFileDrop);
27
+ chatMessages.addEventListener('dragenter', handleDragEnter);
28
+ chatMessages.addEventListener('dragleave', handleDragLeave);
29
+ }
30
+
31
+ console.log('File upload initialized');
32
+ }
33
+
34
+ function openFileUpload() {
35
+ if (!window.currentConversation) {
36
+ MainJS.showError('Please select a conversation first');
37
+ return;
38
+ }
39
+
40
+ const fileInput = document.getElementById('fileInput');
41
+ if (fileInput) {
42
+ fileInput.click();
43
+ }
44
+ }
45
+
46
+ function openImageUpload() {
47
+ if (!window.currentConversation) {
48
+ MainJS.showError('Please select a conversation first');
49
+ return;
50
+ }
51
+
52
+ const imageInput = document.getElementById('imageInput');
53
+ if (imageInput) {
54
+ imageInput.click();
55
+ }
56
+ }
57
+
58
+ function handleFileSelect(event) {
59
+ const files = Array.from(event.target.files);
60
+ if (files.length === 0) return;
61
+
62
+ // Reset input value to allow selecting the same file again
63
+ event.target.value = '';
64
+
65
+ files.forEach(file => {
66
+ uploadFile(file);
67
+ });
68
+ }
69
+
70
+ function handleDragOver(event) {
71
+ event.preventDefault();
72
+ event.dataTransfer.dropEffect = 'copy';
73
+ }
74
+
75
+ function handleDragEnter(event) {
76
+ event.preventDefault();
77
+ const chatMessages = document.getElementById('chatMessages');
78
+ if (chatMessages) {
79
+ chatMessages.classList.add('drag-over');
80
+ }
81
+ }
82
+
83
+ function handleDragLeave(event) {
84
+ event.preventDefault();
85
+ // Only remove class if we're leaving the chat messages area entirely
86
+ if (!event.currentTarget.contains(event.relatedTarget)) {
87
+ const chatMessages = document.getElementById('chatMessages');
88
+ if (chatMessages) {
89
+ chatMessages.classList.remove('drag-over');
90
+ }
91
+ }
92
+ }
93
+
94
+ function handleFileDrop(event) {
95
+ event.preventDefault();
96
+
97
+ const chatMessages = document.getElementById('chatMessages');
98
+ if (chatMessages) {
99
+ chatMessages.classList.remove('drag-over');
100
+ }
101
+
102
+ if (!window.currentConversation) {
103
+ MainJS.showError('Please select a conversation first');
104
+ return;
105
+ }
106
+
107
+ const files = Array.from(event.dataTransfer.files);
108
+ files.forEach(file => {
109
+ uploadFile(file);
110
+ });
111
+ }
112
+
113
+ async function uploadFile(file) {
114
+ if (!window.currentConversation) {
115
+ MainJS.showError('Please select a conversation first');
116
+ return;
117
+ }
118
+
119
+ // Validate file size (100MB limit)
120
+ const maxSize = 100 * 1024 * 1024; // 100MB
121
+ if (file.size > maxSize) {
122
+ MainJS.showError(`File "${file.name}" is too large. Maximum size is 100MB.`);
123
+ return;
124
+ }
125
+
126
+ // Validate file type
127
+ const allowedExtensions = [
128
+ 'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif', 'mp3', 'wav', 'ogg', 'm4a',
129
+ 'mp4', 'avi', 'mov', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx',
130
+ 'zip', 'rar', '7z', 'apk', 'exe', 'dmg', 'deb', 'rpm'
131
+ ];
132
+
133
+ const fileExtension = file.name.split('.').pop().toLowerCase();
134
+ if (!allowedExtensions.includes(fileExtension)) {
135
+ MainJS.showError(`File type "${fileExtension}" is not allowed.`);
136
+ return;
137
+ }
138
+
139
+ // Create progress UI
140
+ const progressId = createProgressUI(file);
141
+
142
+ try {
143
+ // Create form data
144
+ const formData = new FormData();
145
+ formData.append('file', file);
146
+ formData.append('conversation_id', window.currentConversation);
147
+
148
+ // Upload with progress tracking
149
+ const response = await uploadWithProgress(formData, progressId);
150
+
151
+ if (response.success) {
152
+ updateProgressUI(progressId, 100, 'Upload complete');
153
+ MainJS.showSuccess(`File "${file.name}" uploaded successfully!`);
154
+
155
+ // Remove progress UI after a delay
156
+ setTimeout(() => {
157
+ removeProgressUI(progressId);
158
+ }, 2000);
159
+
160
+ // Reload messages and conversations
161
+ await loadMessages(window.currentConversation);
162
+ await loadConversations();
163
+
164
+ } else {
165
+ updateProgressUI(progressId, 0, 'Upload failed: ' + response.message);
166
+ MainJS.showError('Failed to upload file: ' + response.message);
167
+
168
+ // Remove progress UI after delay
169
+ setTimeout(() => {
170
+ removeProgressUI(progressId);
171
+ }, 3000);
172
+ }
173
+
174
+ } catch (error) {
175
+ console.error('Error uploading file:', error);
176
+ updateProgressUI(progressId, 0, 'Upload failed');
177
+ MainJS.showError('Failed to upload file: ' + error.message);
178
+
179
+ // Remove progress UI after delay
180
+ setTimeout(() => {
181
+ removeProgressUI(progressId);
182
+ }, 3000);
183
+ }
184
+ }
185
+
186
+ function uploadWithProgress(formData, progressId) {
187
+ return new Promise((resolve, reject) => {
188
+ const xhr = new XMLHttpRequest();
189
+
190
+ // Track upload progress
191
+ xhr.upload.addEventListener('progress', (event) => {
192
+ if (event.lengthComputable) {
193
+ const percentComplete = (event.loaded / event.total) * 100;
194
+ updateProgressUI(progressId, percentComplete, 'Uploading...');
195
+ }
196
+ });
197
+
198
+ // Handle completion
199
+ xhr.addEventListener('load', () => {
200
+ try {
201
+ const response = JSON.parse(xhr.responseText);
202
+ resolve(response);
203
+ } catch (error) {
204
+ reject(new Error('Invalid response format'));
205
+ }
206
+ });
207
+
208
+ // Handle errors
209
+ xhr.addEventListener('error', () => {
210
+ reject(new Error('Network error during upload'));
211
+ });
212
+
213
+ xhr.addEventListener('abort', () => {
214
+ reject(new Error('Upload cancelled'));
215
+ });
216
+
217
+ // Start upload
218
+ xhr.open('POST', '/api/upload_file');
219
+ xhr.send(formData);
220
+ });
221
+ }
222
+
223
+ function createProgressUI(file) {
224
+ const progressId = 'progress_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
225
+ const chatMessages = document.getElementById('chatMessages');
226
+
227
+ if (!chatMessages) return progressId;
228
+
229
+ const progressElement = document.createElement('div');
230
+ progressElement.id = progressId;
231
+ progressElement.className = 'upload-progress';
232
+
233
+ const iconClass = MainJS.getFileIconClass(file.type);
234
+ const iconColor = MainJS.getFileIconColor(file.type);
235
+
236
+ progressElement.innerHTML = `
237
+ <div class="d-flex align-items-center mb-2">
238
+ <div class="file-icon ${iconColor} me-3">
239
+ <i class="${iconClass}"></i>
240
+ </div>
241
+ <div class="flex-grow-1">
242
+ <div class="fw-bold text-truncate">${MainJS.escapeHtml(file.name)}</div>
243
+ <small class="text-muted">${MainJS.formatFileSize(file.size)}</small>
244
+ </div>
245
+ </div>
246
+ <div class="progress mb-2" style="height: 4px;">
247
+ <div class="progress-bar bg-success" role="progressbar" style="width: 0%"></div>
248
+ </div>
249
+ <div class="progress-status small text-muted">Preparing upload...</div>
250
+ `;
251
+
252
+ chatMessages.appendChild(progressElement);
253
+ chatMessages.scrollTop = chatMessages.scrollHeight;
254
+
255
+ return progressId;
256
+ }
257
+
258
+ function updateProgressUI(progressId, percent, status) {
259
+ const progressElement = document.getElementById(progressId);
260
+ if (!progressElement) return;
261
+
262
+ const progressBar = progressElement.querySelector('.progress-bar');
263
+ const statusElement = progressElement.querySelector('.progress-status');
264
+
265
+ if (progressBar) {
266
+ progressBar.style.width = percent + '%';
267
+ progressBar.setAttribute('aria-valuenow', percent);
268
+ }
269
+
270
+ if (statusElement) {
271
+ statusElement.textContent = status;
272
+ }
273
+
274
+ // Change color based on status
275
+ if (status.includes('failed') || status.includes('error')) {
276
+ if (progressBar) {
277
+ progressBar.classList.remove('bg-success');
278
+ progressBar.classList.add('bg-danger');
279
+ }
280
+ if (statusElement) {
281
+ statusElement.classList.add('text-danger');
282
+ }
283
+ } else if (status.includes('complete')) {
284
+ if (progressBar) {
285
+ progressBar.classList.remove('bg-success');
286
+ progressBar.classList.add('bg-success');
287
+ }
288
+ if (statusElement) {
289
+ statusElement.classList.add('text-success');
290
+ }
291
+ }
292
+ }
293
+
294
+ function removeProgressUI(progressId) {
295
+ const progressElement = document.getElementById(progressId);
296
+ if (progressElement) {
297
+ progressElement.remove();
298
+ }
299
+ }
300
+
301
+ // File preview functionality
302
+ function previewFile(fileUrl, fileName, fileType) {
303
+ if (fileType.startsWith('image/')) {
304
+ showImagePreview(fileUrl, fileName);
305
+ } else if (fileType.startsWith('text/') || fileType.includes('pdf')) {
306
+ window.open(fileUrl, '_blank');
307
+ } else {
308
+ // For other file types, just download
309
+ downloadFileFromUrl(fileUrl, fileName);
310
+ }
311
+ }
312
+
313
+ function showImagePreview(imageUrl, fileName) {
314
+ // Create modal for image preview
315
+ const modal = document.createElement('div');
316
+ modal.className = 'modal fade';
317
+ modal.setAttribute('tabindex', '-1');
318
+
319
+ modal.innerHTML = `
320
+ <div class="modal-dialog modal-lg modal-dialog-centered">
321
+ <div class="modal-content">
322
+ <div class="modal-header">
323
+ <h5 class="modal-title">${MainJS.escapeHtml(fileName)}</h5>
324
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
325
+ </div>
326
+ <div class="modal-body text-center">
327
+ <img src="${imageUrl}" alt="${MainJS.escapeHtml(fileName)}" class="img-fluid" style="max-height: 70vh;">
328
+ </div>
329
+ <div class="modal-footer">
330
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
331
+ <button type="button" class="btn btn-success" onclick="downloadFileFromUrl('${imageUrl}', '${fileName}')">
332
+ <i class="fas fa-download me-2"></i>Download
333
+ </button>
334
+ </div>
335
+ </div>
336
+ </div>
337
+ `;
338
+
339
+ document.body.appendChild(modal);
340
+
341
+ // Show modal
342
+ const bsModal = new bootstrap.Modal(modal);
343
+ bsModal.show();
344
+
345
+ // Remove modal from DOM when hidden
346
+ modal.addEventListener('hidden.bs.modal', () => {
347
+ modal.remove();
348
+ });
349
+ }
350
+
351
+ function downloadFileFromUrl(url, fileName) {
352
+ const link = document.createElement('a');
353
+ link.href = url;
354
+ link.download = fileName;
355
+ link.style.display = 'none';
356
+ document.body.appendChild(link);
357
+ link.click();
358
+ document.body.removeChild(link);
359
+ }
360
+
361
+ // File type validation
362
+ function validateFileType(file) {
363
+ const allowedTypes = [
364
+ // Images
365
+ 'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
366
+ // Audio
367
+ 'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a',
368
+ // Video
369
+ 'video/mp4', 'video/avi', 'video/quicktime', 'video/webm',
370
+ // Documents
371
+ 'application/pdf',
372
+ 'application/msword',
373
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
374
+ 'application/vnd.ms-excel',
375
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
376
+ 'application/vnd.ms-powerpoint',
377
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
378
+ // Archives
379
+ 'application/zip',
380
+ 'application/x-rar-compressed',
381
+ 'application/x-7z-compressed',
382
+ // Text
383
+ 'text/plain',
384
+ // Applications
385
+ 'application/vnd.android.package-archive'
386
+ ];
387
+
388
+ return allowedTypes.includes(file.type) || file.type === '';
389
+ }
390
+
391
+ // File size formatting (already available in main.js, but keeping for reference)
392
+ function formatFileSize(bytes) {
393
+ if (bytes === 0) return '0 Bytes';
394
+ const k = 1024;
395
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
396
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
397
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
398
+ }
399
+
400
+ // Add CSS for drag and drop visual feedback
401
+ const dragDropStyles = document.createElement('style');
402
+ dragDropStyles.textContent = `
403
+ .chat-messages.drag-over {
404
+ border: 2px dashed #25d366;
405
+ background-color: rgba(37, 211, 102, 0.1);
406
+ }
407
+
408
+ .chat-messages.drag-over::after {
409
+ content: "Drop files here to upload";
410
+ position: absolute;
411
+ top: 50%;
412
+ left: 50%;
413
+ transform: translate(-50%, -50%);
414
+ background: rgba(37, 211, 102, 0.9);
415
+ color: white;
416
+ padding: 1rem 2rem;
417
+ border-radius: 8px;
418
+ font-weight: bold;
419
+ z-index: 1000;
420
+ pointer-events: none;
421
+ }
422
+ `;
423
+ document.head.appendChild(dragDropStyles);
424
+
425
+ // Export functions for global access
426
+ window.FileJS = {
427
+ openFileUpload,
428
+ openImageUpload,
429
+ uploadFile,
430
+ previewFile,
431
+ validateFileType,
432
+ formatFileSize
433
+ };
static/js/main.js ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Main JavaScript utilities and helpers
2
+
3
+ // Global variables
4
+ window.currentUser = null;
5
+ window.currentConversation = null;
6
+
7
+ // Utility functions
8
+ function formatTime(timestamp) {
9
+ const date = new Date(timestamp);
10
+ const now = new Date();
11
+ const diff = now - date;
12
+
13
+ if (diff < 60000) { // Less than 1 minute
14
+ return 'now';
15
+ } else if (diff < 3600000) { // Less than 1 hour
16
+ return Math.floor(diff / 60000) + 'm';
17
+ } else if (diff < 86400000) { // Less than 1 day
18
+ return Math.floor(diff / 3600000) + 'h';
19
+ } else {
20
+ return date.toLocaleDateString();
21
+ }
22
+ }
23
+
24
+ function formatMessageTime(timestamp) {
25
+ const date = new Date(timestamp);
26
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
27
+ }
28
+
29
+ function escapeHtml(unsafe) {
30
+ return unsafe
31
+ .replace(/&/g, "&amp;")
32
+ .replace(/</g, "&lt;")
33
+ .replace(/>/g, "&gt;")
34
+ .replace(/"/g, "&quot;")
35
+ .replace(/'/g, "&#039;");
36
+ }
37
+
38
+ function showToast(message, type = 'info') {
39
+ // Create toast element
40
+ const toast = document.createElement('div');
41
+ toast.className = `toast align-items-center text-white bg-${type === 'error' ? 'danger' : 'success'} border-0`;
42
+ toast.setAttribute('role', 'alert');
43
+
44
+ toast.innerHTML = `
45
+ <div class="d-flex">
46
+ <div class="toast-body">
47
+ ${escapeHtml(message)}
48
+ </div>
49
+ <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
50
+ </div>
51
+ `;
52
+
53
+ // Add to page
54
+ let toastContainer = document.querySelector('.toast-container');
55
+ if (!toastContainer) {
56
+ toastContainer = document.createElement('div');
57
+ toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
58
+ document.body.appendChild(toastContainer);
59
+ }
60
+
61
+ toastContainer.appendChild(toast);
62
+
63
+ // Show toast
64
+ const bsToast = new bootstrap.Toast(toast);
65
+ bsToast.show();
66
+
67
+ // Remove from DOM after hidden
68
+ toast.addEventListener('hidden.bs.toast', () => {
69
+ toast.remove();
70
+ });
71
+ }
72
+
73
+ function showError(message) {
74
+ showToast(message, 'error');
75
+ }
76
+
77
+ function showSuccess(message) {
78
+ showToast(message, 'success');
79
+ }
80
+
81
+ // API helper functions
82
+ async function apiRequest(url, options = {}) {
83
+ try {
84
+ const response = await fetch(url, {
85
+ ...options,
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ ...options.headers
89
+ }
90
+ });
91
+
92
+ if (!response.ok) {
93
+ throw new Error(`HTTP error! status: ${response.status}`);
94
+ }
95
+
96
+ return await response.json();
97
+ } catch (error) {
98
+ console.error('API request failed:', error);
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ // File helper functions
104
+ function formatFileSize(bytes) {
105
+ if (bytes === 0) return '0 Bytes';
106
+ const k = 1024;
107
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
108
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
109
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
110
+ }
111
+
112
+ function getFileIconClass(fileType) {
113
+ if (fileType.startsWith('image/')) return 'fas fa-image';
114
+ if (fileType.startsWith('audio/')) return 'fas fa-music';
115
+ if (fileType.startsWith('video/')) return 'fas fa-video';
116
+ if (fileType.includes('pdf')) return 'fas fa-file-pdf';
117
+ if (fileType.includes('word') || fileType.includes('document')) return 'fas fa-file-word';
118
+ if (fileType.includes('excel') || fileType.includes('sheet')) return 'fas fa-file-excel';
119
+ if (fileType.includes('powerpoint') || fileType.includes('presentation')) return 'fas fa-file-powerpoint';
120
+ if (fileType.includes('zip') || fileType.includes('archive') || fileType.includes('rar') || fileType.includes('7z')) return 'fas fa-file-archive';
121
+ if (fileType.includes('apk')) return 'fab fa-android';
122
+ return 'fas fa-file';
123
+ }
124
+
125
+ function getFileIconColor(fileType) {
126
+ if (fileType.startsWith('image/')) return 'image';
127
+ if (fileType.startsWith('audio/')) return 'audio';
128
+ if (fileType.startsWith('video/')) return 'video';
129
+ if (fileType.includes('pdf')) return 'pdf';
130
+ if (fileType.includes('word') || fileType.includes('document')) return 'document';
131
+ if (fileType.includes('excel') || fileType.includes('sheet')) return 'document';
132
+ if (fileType.includes('powerpoint') || fileType.includes('presentation')) return 'document';
133
+ if (fileType.includes('zip') || fileType.includes('archive') || fileType.includes('rar') || fileType.includes('7z')) return 'archive';
134
+ if (fileType.includes('apk')) return 'apk';
135
+ return 'default';
136
+ }
137
+
138
+ // Online status management
139
+ let statusUpdateInterval;
140
+
141
+ function startStatusUpdates() {
142
+ // Update online status every 30 seconds
143
+ statusUpdateInterval = setInterval(updateOnlineStatus, 30000);
144
+
145
+ // Update on page visibility change
146
+ document.addEventListener('visibilitychange', () => {
147
+ if (!document.hidden) {
148
+ updateOnlineStatus();
149
+ }
150
+ });
151
+
152
+ // Update on page unload
153
+ window.addEventListener('beforeunload', () => {
154
+ updateOnlineStatus(false);
155
+ });
156
+
157
+ // Initial update
158
+ updateOnlineStatus();
159
+ }
160
+
161
+ async function updateOnlineStatus(online = true) {
162
+ try {
163
+ await apiRequest('/api/update_status', {
164
+ method: 'POST',
165
+ body: JSON.stringify({ online })
166
+ });
167
+ } catch (error) {
168
+ console.error('Failed to update online status:', error);
169
+ }
170
+ }
171
+
172
+ function stopStatusUpdates() {
173
+ if (statusUpdateInterval) {
174
+ clearInterval(statusUpdateInterval);
175
+ statusUpdateInterval = null;
176
+ }
177
+ }
178
+
179
+ // Initialize common functionality
180
+ document.addEventListener('DOMContentLoaded', () => {
181
+ // Start status updates if on chat page
182
+ if (document.body.classList.contains('chat-page')) {
183
+ startStatusUpdates();
184
+
185
+ // Handle mobile keyboard viewport changes
186
+ if (/Mobi|Android/i.test(navigator.userAgent)) {
187
+ handleMobileViewport();
188
+ }
189
+ }
190
+
191
+ // Handle page unload
192
+ window.addEventListener('beforeunload', () => {
193
+ stopStatusUpdates();
194
+ });
195
+ });
196
+
197
+ // Handle mobile viewport changes when keyboard appears/disappears
198
+ function handleMobileViewport() {
199
+ let initialViewportHeight = window.innerHeight;
200
+
201
+ window.addEventListener('resize', () => {
202
+ const currentHeight = window.innerHeight;
203
+ const chatMessages = document.getElementById('chatMessages');
204
+
205
+ if (chatMessages) {
206
+ // If keyboard is shown (viewport shrunk significantly)
207
+ if (currentHeight < initialViewportHeight * 0.75) {
208
+ // Scroll to bottom when keyboard appears
209
+ setTimeout(() => {
210
+ chatMessages.scrollTop = chatMessages.scrollHeight;
211
+ }, 300);
212
+ }
213
+ }
214
+ });
215
+
216
+ // Handle visual viewport API if available
217
+ if (window.visualViewport) {
218
+ window.visualViewport.addEventListener('resize', () => {
219
+ const chatMessages = document.getElementById('chatMessages');
220
+ if (chatMessages) {
221
+ setTimeout(() => {
222
+ chatMessages.scrollTop = chatMessages.scrollHeight;
223
+ }, 100);
224
+ }
225
+ });
226
+ }
227
+ }
228
+
229
+ // Export functions for use in other scripts
230
+ window.MainJS = {
231
+ formatTime,
232
+ formatMessageTime,
233
+ escapeHtml,
234
+ showToast,
235
+ showError,
236
+ showSuccess,
237
+ apiRequest,
238
+ formatFileSize,
239
+ getFileIconClass,
240
+ getFileIconColor,
241
+ updateOnlineStatus
242
+ };
templates/chat.html ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
7
+ <meta http-equiv="Pragma" content="no-cache">
8
+ <meta http-equiv="Expires" content="0">
9
+ <title>Chat - WhatsApp Clone</title>
10
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
11
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
12
+ <link href="{{ url_for('static', filename='css/style.css') }}?v=20250722075000" rel="stylesheet">
13
+ </head>
14
+ <body class="chat-page">
15
+ <!-- Mobile Overlay -->
16
+ <div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleMobileSidebar()"></div>
17
+
18
+ <div class="container-fluid h-100 p-0">
19
+ <div class="row h-100 g-0">
20
+ <!-- Mobile Header (visible only on mobile) -->
21
+ <div class="mobile-header d-md-none">
22
+ <button class="mobile-menu-btn" onclick="toggleMobileSidebar()">
23
+ <i class="fas fa-bars"></i>
24
+ </button>
25
+ <div class="fw-bold">WhatsApp Clone</div>
26
+ <div></div>
27
+ </div>
28
+
29
+ <!-- Sidebar -->
30
+ <div class="col-md-4 col-lg-3 sidebar" id="sidebar">
31
+ <div class="sidebar-header">
32
+ <div class="d-flex align-items-center justify-content-between">
33
+ <div class="d-flex align-items-center">
34
+ <div class="avatar bg-success me-3">
35
+ {{ user.name[0].upper() }}
36
+ </div>
37
+ <div data-user-id="{{ user.user_id }}">
38
+ <div class="fw-bold">{{ user.name }}</div>
39
+ <small class="text-muted">{{ user.unique_id }}</small>
40
+ </div>
41
+ </div>
42
+ <div class="dropdown">
43
+ <button class="btn btn-link text-muted" data-bs-toggle="dropdown">
44
+ <i class="fas fa-ellipsis-v"></i>
45
+ </button>
46
+ <ul class="dropdown-menu">
47
+ <li><a class="dropdown-item" href="/settings"><i class="fas fa-cog me-2"></i>Settings</a></li>
48
+ <li><a class="dropdown-item" href="/logout"><i class="fas fa-sign-out-alt me-2"></i>Logout</a></li>
49
+ </ul>
50
+ </div>
51
+ </div>
52
+ </div>
53
+
54
+ <div class="search-box">
55
+ <div class="input-group">
56
+ <span class="input-group-text"><i class="fas fa-search"></i></span>
57
+ <input type="text" class="form-control" placeholder="Search conversations...">
58
+ </div>
59
+ </div>
60
+
61
+ <div class="new-chat-btn">
62
+ <button class="btn btn-success w-100" data-bs-toggle="modal" data-bs-target="#newChatModal">
63
+ <i class="fas fa-plus me-2"></i>New Chat
64
+ </button>
65
+ </div>
66
+
67
+ <div class="conversations-list" id="conversationsList">
68
+ <!-- Conversations will be loaded here -->
69
+ </div>
70
+ </div>
71
+
72
+ <!-- Chat Area -->
73
+ <div class="col-md-8 col-lg-9 chat-area">
74
+ <div class="welcome-screen" id="welcomeScreen">
75
+ <div class="text-center">
76
+ <i class="fas fa-comments text-muted mb-3" style="font-size: 4rem;"></i>
77
+ <h4 class="text-muted">Welcome to WhatsApp Clone</h4>
78
+ <p class="text-muted">Select a conversation to start messaging</p>
79
+ <div class="mt-4">
80
+ <i class="fas fa-file text-muted me-3"></i>
81
+ <i class="fas fa-microphone text-muted me-3"></i>
82
+ <i class="fas fa-image text-muted"></i>
83
+ </div>
84
+ <small class="text-muted d-block mt-2">Share files, voice messages, and more</small>
85
+ </div>
86
+ </div>
87
+
88
+ <div class="chat-container" id="chatContainer" style="display: none;">
89
+ <div class="chat-header" id="chatHeader">
90
+ <!-- Chat header will be loaded here -->
91
+ </div>
92
+
93
+ <div class="chat-messages" id="chatMessages">
94
+ <!-- Messages will be loaded here -->
95
+ </div>
96
+
97
+ <div class="chat-input">
98
+ <div class="chat-input-toolbar mb-2">
99
+ <button type="button" class="btn btn-sm btn-outline-success me-2" onclick="openFileUpload()">
100
+ <i class="fas fa-paperclip"></i>
101
+ </button>
102
+ <button type="button" class="btn btn-sm btn-outline-success me-2" onclick="openImageUpload()">
103
+ <i class="fas fa-image"></i>
104
+ </button>
105
+ <button type="button" class="btn btn-sm btn-outline-success" id="audioButton" onclick="toggleAudioRecording()">
106
+ <i class="fas fa-microphone"></i>
107
+ </button>
108
+ </div>
109
+
110
+ <form id="messageForm" class="d-flex">
111
+ <input type="text" class="form-control" id="messageInput" placeholder="Type a message..." autocomplete="off">
112
+ <button type="submit" class="btn btn-success ms-2">
113
+ <i class="fas fa-paper-plane"></i>
114
+ </button>
115
+ </form>
116
+
117
+ <!-- Hidden file inputs -->
118
+ <input type="file" id="fileInput" style="display: none;" multiple>
119
+ <input type="file" id="imageInput" style="display: none;" accept="image/*" multiple>
120
+
121
+ <!-- Audio recording UI -->
122
+ <div id="audioRecording" class="audio-recording-ui" style="display: none;">
123
+ <div class="d-flex align-items-center justify-content-between p-3 bg-light rounded">
124
+ <div class="d-flex align-items-center">
125
+ <div class="recording-indicator me-3">
126
+ <i class="fas fa-microphone text-danger"></i>
127
+ </div>
128
+ <div>
129
+ <div class="fw-bold">Recording...</div>
130
+ <small class="text-muted" id="recordingTime">00:00</small>
131
+ </div>
132
+ </div>
133
+ <div>
134
+ <button type="button" class="btn btn-outline-secondary me-2" onclick="cancelAudioRecording()">
135
+ <i class="fas fa-times"></i>
136
+ </button>
137
+ <button type="button" class="btn btn-success" onclick="stopAudioRecording()">
138
+ <i class="fas fa-stop"></i>
139
+ </button>
140
+ </div>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <!-- New Chat Modal -->
150
+ <div class="modal fade" id="newChatModal" tabindex="-1">
151
+ <div class="modal-dialog">
152
+ <div class="modal-content">
153
+ <div class="modal-header">
154
+ <h5 class="modal-title">Start New Chat</h5>
155
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
156
+ </div>
157
+ <div class="modal-body">
158
+ <div class="d-grid gap-2">
159
+ <button class="btn btn-outline-success" onclick="startPrivateChat()">
160
+ <i class="fas fa-user me-2"></i>Private Chat
161
+ </button>
162
+ <button class="btn btn-outline-success" onclick="startGroupChat()">
163
+ <i class="fas fa-users me-2"></i>Group Chat
164
+ </button>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </div>
170
+
171
+ <!-- Private Chat Modal -->
172
+ <div class="modal fade" id="privateChatModal" tabindex="-1">
173
+ <div class="modal-dialog">
174
+ <div class="modal-content">
175
+ <div class="modal-header">
176
+ <h5 class="modal-title">Start Private Chat</h5>
177
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
178
+ </div>
179
+ <div class="modal-body">
180
+ <form id="privateChatForm">
181
+ <div class="mb-3">
182
+ <label for="userIdInput" class="form-label">User ID</label>
183
+ <input type="text" class="form-control" id="userIdInput" placeholder="Enter user's unique ID" required>
184
+ </div>
185
+ <div id="userPreview" style="display: none;" class="alert alert-info">
186
+ <!-- User preview will be shown here -->
187
+ </div>
188
+ <div class="d-grid gap-2">
189
+ <button type="button" class="btn btn-outline-success" onclick="findUser()">
190
+ <i class="fas fa-search me-2"></i>Find User
191
+ </button>
192
+ <button type="submit" class="btn btn-success" id="startChatBtn" style="display: none;">
193
+ <i class="fas fa-comments me-2"></i>Start Chat
194
+ </button>
195
+ </div>
196
+ </form>
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+
202
+ <!-- Group Chat Modal -->
203
+ <div class="modal fade" id="groupChatModal" tabindex="-1">
204
+ <div class="modal-dialog modal-lg">
205
+ <div class="modal-content">
206
+ <div class="modal-header">
207
+ <h5 class="modal-title">Create Group Chat</h5>
208
+ <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
209
+ </div>
210
+ <div class="modal-body">
211
+ <form id="groupChatForm">
212
+ <div class="mb-3">
213
+ <label for="groupNameInput" class="form-label">Group Name</label>
214
+ <input type="text" class="form-control" id="groupNameInput" placeholder="Enter group name" required>
215
+ </div>
216
+ <div class="mb-3">
217
+ <label class="form-label">Add Members (1-9 members)</label>
218
+ <div id="groupMembers">
219
+ <div class="input-group mb-2">
220
+ <input type="text" class="form-control member-input" placeholder="Enter user ID">
221
+ <button type="button" class="btn btn-outline-success" onclick="addMemberField()">
222
+ <i class="fas fa-plus"></i>
223
+ </button>
224
+ </div>
225
+ </div>
226
+ </div>
227
+ <div class="d-grid">
228
+ <button type="submit" class="btn btn-success">
229
+ <i class="fas fa-users me-2"></i>Create Group
230
+ </button>
231
+ </div>
232
+ </form>
233
+ </div>
234
+ </div>
235
+ </div>
236
+ </div>
237
+
238
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
239
+ <script src="{{ url_for('static', filename='js/main.js') }}?v=20250722073000"></script>
240
+ <script src="{{ url_for('static', filename='js/audio.js') }}?v=20250722073000"></script>
241
+ <script src="{{ url_for('static', filename='js/files.js') }}?v=20250722073000"></script>
242
+ <script src="{{ url_for('static', filename='js/chat.js') }}?v=20250722073000"></script>
243
+
244
+ <!-- Make functions available globally for onclick handlers -->
245
+ <script>
246
+ // Set current user data for JavaScript
247
+ window.currentUserId = '{{ user.user_id }}';
248
+ window.currentUserName = '{{ user.name }}';
249
+
250
+ window.toggleMobileSidebar = toggleMobileSidebar;
251
+ window.startPrivateChat = startPrivateChat;
252
+ window.startGroupChat = startGroupChat;
253
+ window.findUser = findUser;
254
+ window.addMemberField = addMemberField;
255
+ window.removeMemberField = removeMemberField;
256
+ window.selectConversation = selectConversation;
257
+ window.openFileUpload = openFileUpload;
258
+ window.openImageUpload = openImageUpload;
259
+ window.toggleAudioRecording = toggleAudioRecording;
260
+ window.cancelAudioRecording = cancelAudioRecording;
261
+ window.stopAudioRecording = stopAudioRecording;
262
+ </script>
263
+ </body>
264
+ </html>
templates/landing.html ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>WhatsApp Clone</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
9
+ <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
10
+ </head>
11
+ <body class="landing-page">
12
+ <div class="container-fluid h-100">
13
+ <div class="row h-100">
14
+ <div class="col-lg-6 d-flex align-items-center justify-content-center">
15
+ <div class="landing-content text-center">
16
+ <div class="logo-container mb-4">
17
+ <img src="{{ url_for('static', filename='images/logo.svg') }}" alt="WhatsApp Clone" class="logo">
18
+ </div>
19
+ <h1 class="display-4 fw-bold text-success mb-3">WhatsApp Clone</h1>
20
+ <p class="lead mb-4">Connect with friends and family instantly. Send messages, share files, record voice messages, and stay in touch wherever you are.</p>
21
+
22
+ <div class="features mb-5">
23
+ <div class="feature-item d-flex align-items-center mb-3">
24
+ <i class="fas fa-comments text-success me-3"></i>
25
+ <span>Real-time messaging</span>
26
+ </div>
27
+ <div class="feature-item d-flex align-items-center mb-3">
28
+ <i class="fas fa-users text-success me-3"></i>
29
+ <span>Group chats (3-10 members)</span>
30
+ </div>
31
+ <div class="feature-item d-flex align-items-center mb-3">
32
+ <i class="fas fa-file text-success me-3"></i>
33
+ <span>File sharing & downloads</span>
34
+ </div>
35
+ <div class="feature-item d-flex align-items-center mb-3">
36
+ <i class="fas fa-microphone text-success me-3"></i>
37
+ <span>Voice message recording</span>
38
+ </div>
39
+ <div class="feature-item d-flex align-items-center mb-3">
40
+ <i class="fas fa-check-double text-success me-3"></i>
41
+ <span>Message status tracking</span>
42
+ </div>
43
+ <div class="feature-item d-flex align-items-center mb-3">
44
+ <i class="fas fa-circle text-success me-3"></i>
45
+ <span>Online/offline status</span>
46
+ </div>
47
+ </div>
48
+
49
+ <button class="btn btn-success btn-lg px-5" onclick="window.location.href='/register'">
50
+ <i class="fas fa-user-plus me-2"></i>Create Account
51
+ </button>
52
+ </div>
53
+ </div>
54
+
55
+ <div class="col-lg-6 d-none d-lg-flex align-items-center justify-content-center bg-light">
56
+ <div class="chat-preview">
57
+ <div class="phone-mockup">
58
+ <div class="phone-screen">
59
+ <div class="chat-header">
60
+ <div class="d-flex align-items-center">
61
+ <div class="avatar bg-success me-2"></div>
62
+ <div>
63
+ <div class="fw-bold">John Doe</div>
64
+ <small class="text-success">online</small>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ <div class="chat-messages">
69
+ <div class="message received">
70
+ <div class="message-bubble">Hello there! 👋</div>
71
+ </div>
72
+ <div class="message sent">
73
+ <div class="message-bubble">Hi! How are you?</div>
74
+ </div>
75
+ <div class="message received">
76
+ <div class="message-bubble">📎 document.pdf</div>
77
+ </div>
78
+ <div class="message sent">
79
+ <div class="message-bubble">🎵 Voice message</div>
80
+ </div>
81
+ <div class="message received">
82
+ <div class="message-bubble">Thanks for sharing! 😊</div>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </div>
88
+ </div>
89
+ </div>
90
+ </div>
91
+
92
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
93
+ </body>
94
+ </html>
templates/register.html ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>Create Account - WhatsApp Clone</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
9
+ <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
10
+ </head>
11
+ <body class="register-page">
12
+ <div class="container-fluid h-100">
13
+ <div class="row h-100 justify-content-center align-items-center">
14
+ <div class="col-md-6 col-lg-4">
15
+ <div class="register-card">
16
+ <div class="text-center mb-4">
17
+ <img src="{{ url_for('static', filename='images/logo.svg') }}" alt="WhatsApp Clone" class="logo-small">
18
+ <h2 class="mt-3">Create Account</h2>
19
+ <p class="text-muted">Join our messaging platform</p>
20
+ </div>
21
+
22
+ <form id="registerForm">
23
+ <div class="mb-3">
24
+ <label for="name" class="form-label">Full Name</label>
25
+ <div class="input-group">
26
+ <span class="input-group-text"><i class="fas fa-user"></i></span>
27
+ <input type="text" class="form-control" id="name" name="name" required>
28
+ </div>
29
+ </div>
30
+
31
+ <div class="mb-3">
32
+ <label for="email" class="form-label">Email Address</label>
33
+ <div class="input-group">
34
+ <span class="input-group-text"><i class="fas fa-envelope"></i></span>
35
+ <input type="email" class="form-control" id="email" name="email" required>
36
+ </div>
37
+ </div>
38
+
39
+ <div class="d-grid mb-3">
40
+ <button type="submit" class="btn btn-success btn-lg">
41
+ <i class="fas fa-user-plus me-2"></i>Create Account
42
+ </button>
43
+ </div>
44
+ </form>
45
+
46
+ <div class="text-center">
47
+ <a href="/" class="text-muted">
48
+ <i class="fas fa-arrow-left me-1"></i>Back to Home
49
+ </a>
50
+ </div>
51
+
52
+ <div id="alert-container"></div>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ </div>
57
+
58
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
59
+ <script src="{{ url_for('static', filename='js/main.js') }}"></script>
60
+ <script>
61
+ document.getElementById('registerForm').addEventListener('submit', async (e) => {
62
+ e.preventDefault();
63
+
64
+ const formData = new FormData(e.target);
65
+ const data = {
66
+ name: formData.get('name').trim(),
67
+ email: formData.get('email').trim()
68
+ };
69
+
70
+ if (!data.name || !data.email) {
71
+ showAlert('Please fill in all fields', 'danger');
72
+ return;
73
+ }
74
+
75
+ try {
76
+ const response = await fetch('/api/register', {
77
+ method: 'POST',
78
+ headers: {
79
+ 'Content-Type': 'application/json',
80
+ },
81
+ body: JSON.stringify(data)
82
+ });
83
+
84
+ const result = await response.json();
85
+
86
+ if (result.success) {
87
+ showAlert('Account created successfully! Redirecting...', 'success');
88
+ setTimeout(() => {
89
+ window.location.href = '/chat';
90
+ }, 2000);
91
+ } else {
92
+ showAlert(result.message || 'Registration failed', 'danger');
93
+ }
94
+ } catch (error) {
95
+ console.error('Registration error:', error);
96
+ showAlert('Registration failed. Please try again.', 'danger');
97
+ }
98
+ });
99
+
100
+ function showAlert(message, type) {
101
+ const alertContainer = document.getElementById('alert-container');
102
+ const alert = document.createElement('div');
103
+ alert.className = `alert alert-${type} mt-3`;
104
+ alert.textContent = message;
105
+ alertContainer.innerHTML = '';
106
+ alertContainer.appendChild(alert);
107
+
108
+ if (type === 'success') {
109
+ setTimeout(() => {
110
+ alert.remove();
111
+ }, 3000);
112
+ }
113
+ }
114
+ </script>
115
+ </body>
116
+ </html>
templates/settings.html ADDED
@@ -0,0 +1,155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
6
+ <title>Settings - WhatsApp Clone</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
9
+ <link href="{{ url_for('static', filename='css/style.css') }}" rel="stylesheet">
10
+ </head>
11
+ <body class="settings-page">
12
+ <div class="container mt-4">
13
+ <div class="row justify-content-center">
14
+ <div class="col-md-8 col-lg-6">
15
+ <div class="card">
16
+ <div class="card-header d-flex align-items-center">
17
+ <a href="/chat" class="btn btn-link text-decoration-none me-3">
18
+ <i class="fas fa-arrow-left"></i>
19
+ </a>
20
+ <h4 class="mb-0">Settings</h4>
21
+ </div>
22
+ <div class="card-body">
23
+ <div class="text-center mb-4">
24
+ <div class="avatar-large bg-success mx-auto mb-3">
25
+ {{ user.name[0].upper() }}
26
+ </div>
27
+ <h5>{{ user.name }}</h5>
28
+ <p class="text-muted">{{ user.email }}</p>
29
+ </div>
30
+
31
+ <div class="settings-section">
32
+ <h6 class="text-muted mb-3">Account Information</h6>
33
+
34
+ <div class="setting-item">
35
+ <div class="d-flex align-items-center justify-content-between">
36
+ <div>
37
+ <div class="fw-bold">Unique ID</div>
38
+ <small class="text-muted">Share this ID with others to connect</small>
39
+ </div>
40
+ <div class="d-flex align-items-center">
41
+ <code id="uniqueId" class="me-2">{{ user.unique_id }}</code>
42
+ <button class="btn btn-sm btn-outline-success" onclick="copyUniqueId()">
43
+ <i class="fas fa-copy"></i>
44
+ </button>
45
+ </div>
46
+ </div>
47
+ </div>
48
+
49
+ <div class="setting-item">
50
+ <div class="d-flex align-items-center justify-content-between">
51
+ <div>
52
+ <div class="fw-bold">Name</div>
53
+ <small class="text-muted">{{ user.name }}</small>
54
+ </div>
55
+ <i class="fas fa-user text-muted"></i>
56
+ </div>
57
+ </div>
58
+
59
+ <div class="setting-item">
60
+ <div class="d-flex align-items-center justify-content-between">
61
+ <div>
62
+ <div class="fw-bold">Email</div>
63
+ <small class="text-muted">{{ user.email }}</small>
64
+ </div>
65
+ <i class="fas fa-envelope text-muted"></i>
66
+ </div>
67
+ </div>
68
+ </div>
69
+
70
+ <div class="settings-section">
71
+ <h6 class="text-muted mb-3">Features</h6>
72
+
73
+ <div class="setting-item">
74
+ <div class="d-flex align-items-center justify-content-between">
75
+ <div>
76
+ <div class="fw-bold">File Sharing</div>
77
+ <small class="text-muted">Share documents, images, and files</small>
78
+ </div>
79
+ <i class="fas fa-file text-muted"></i>
80
+ </div>
81
+ </div>
82
+
83
+ <div class="setting-item">
84
+ <div class="d-flex align-items-center justify-content-between">
85
+ <div>
86
+ <div class="fw-bold">Voice Messages</div>
87
+ <small class="text-muted">Record and send audio messages</small>
88
+ </div>
89
+ <i class="fas fa-microphone text-muted"></i>
90
+ </div>
91
+ </div>
92
+
93
+ <div class="setting-item">
94
+ <div class="d-flex align-items-center justify-content-between">
95
+ <div>
96
+ <div class="fw-bold">Privacy</div>
97
+ <small class="text-muted">Manage your privacy settings</small>
98
+ </div>
99
+ <i class="fas fa-shield-alt text-muted"></i>
100
+ </div>
101
+ </div>
102
+ </div>
103
+
104
+ <div class="d-grid gap-2 mt-4">
105
+ <a href="/chat" class="btn btn-success">
106
+ <i class="fas fa-arrow-left me-2"></i>Back to Chat
107
+ </a>
108
+ <a href="/logout" class="btn btn-outline-danger">
109
+ <i class="fas fa-sign-out-alt me-2"></i>Logout
110
+ </a>
111
+ </div>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ </div>
117
+
118
+ <!-- Toast for copy confirmation -->
119
+ <div class="toast-container position-fixed top-0 end-0 p-3">
120
+ <div id="copyToast" class="toast" role="alert">
121
+ <div class="toast-header">
122
+ <i class="fas fa-check-circle text-success me-2"></i>
123
+ <strong class="me-auto">Copied!</strong>
124
+ <button type="button" class="btn-close" data-bs-dismiss="toast"></button>
125
+ </div>
126
+ <div class="toast-body">
127
+ Unique ID copied to clipboard
128
+ </div>
129
+ </div>
130
+ </div>
131
+
132
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
133
+ <script>
134
+ function copyUniqueId() {
135
+ const uniqueId = document.getElementById('uniqueId').textContent;
136
+ navigator.clipboard.writeText(uniqueId).then(() => {
137
+ const toast = new bootstrap.Toast(document.getElementById('copyToast'));
138
+ toast.show();
139
+ }).catch(err => {
140
+ console.error('Failed to copy: ', err);
141
+ // Fallback for older browsers
142
+ const textArea = document.createElement('textarea');
143
+ textArea.value = uniqueId;
144
+ document.body.appendChild(textArea);
145
+ textArea.select();
146
+ document.execCommand('copy');
147
+ document.body.removeChild(textArea);
148
+
149
+ const toast = new bootstrap.Toast(document.getElementById('copyToast'));
150
+ toast.show();
151
+ });
152
+ }
153
+ </script>
154
+ </body>
155
+ </html>
uv.lock ADDED
@@ -0,0 +1,510 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version = 1
2
+ requires-python = ">=3.11"
3
+
4
+ [[package]]
5
+ name = "blinker"
6
+ version = "1.9.0"
7
+ source = { registry = "https://pypi.org/simple" }
8
+ sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 }
9
+ wheels = [
10
+ { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 },
11
+ ]
12
+
13
+ [[package]]
14
+ name = "certifi"
15
+ version = "2025.7.14"
16
+ source = { registry = "https://pypi.org/simple" }
17
+ sdist = { url = "https://files.pythonhosted.org/packages/b3/76/52c535bcebe74590f296d6c77c86dabf761c41980e1347a2422e4aa2ae41/certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995", size = 163981 }
18
+ wheels = [
19
+ { url = "https://files.pythonhosted.org/packages/4f/52/34c6cf5bb9285074dc3531c437b3919e825d976fde097a7a73f79e726d03/certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2", size = 162722 },
20
+ ]
21
+
22
+ [[package]]
23
+ name = "charset-normalizer"
24
+ version = "3.4.2"
25
+ source = { registry = "https://pypi.org/simple" }
26
+ sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 }
27
+ wheels = [
28
+ { 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 },
29
+ { 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 },
30
+ { 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 },
31
+ { 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 },
32
+ { 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 },
33
+ { 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 },
34
+ { 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 },
35
+ { 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 },
36
+ { 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 },
37
+ { 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 },
38
+ { 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 },
39
+ { url = "https://files.pythonhosted.org/packages/90/07/c5fd7c11eafd561bb51220d600a788f1c8d77c5eef37ee49454cc5c35575/charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a", size = 98106 },
40
+ { url = "https://files.pythonhosted.org/packages/a8/05/5e33dbef7e2f773d672b6d79f10ec633d4a71cd96db6673625838a4fd532/charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28", size = 105402 },
41
+ { 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 },
42
+ { 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 },
43
+ { 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 },
44
+ { 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 },
45
+ { 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 },
46
+ { 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 },
47
+ { 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 },
48
+ { 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 },
49
+ { 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 },
50
+ { 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 },
51
+ { 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 },
52
+ { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 },
53
+ { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 },
54
+ { 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 },
55
+ { 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 },
56
+ { 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 },
57
+ { 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 },
58
+ { 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 },
59
+ { 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 },
60
+ { 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 },
61
+ { 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 },
62
+ { 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 },
63
+ { 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 },
64
+ { 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 },
65
+ { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 },
66
+ { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 },
67
+ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 },
68
+ ]
69
+
70
+ [[package]]
71
+ name = "click"
72
+ version = "8.2.1"
73
+ source = { registry = "https://pypi.org/simple" }
74
+ dependencies = [
75
+ { name = "colorama", marker = "sys_platform == 'win32'" },
76
+ ]
77
+ sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342 }
78
+ wheels = [
79
+ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215 },
80
+ ]
81
+
82
+ [[package]]
83
+ name = "colorama"
84
+ version = "0.4.6"
85
+ source = { registry = "https://pypi.org/simple" }
86
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
87
+ wheels = [
88
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
89
+ ]
90
+
91
+ [[package]]
92
+ name = "dnspython"
93
+ version = "2.7.0"
94
+ source = { registry = "https://pypi.org/simple" }
95
+ sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 }
96
+ wheels = [
97
+ { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 },
98
+ ]
99
+
100
+ [[package]]
101
+ name = "email-validator"
102
+ version = "2.2.0"
103
+ source = { registry = "https://pypi.org/simple" }
104
+ dependencies = [
105
+ { name = "dnspython" },
106
+ { name = "idna" },
107
+ ]
108
+ sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 }
109
+ wheels = [
110
+ { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 },
111
+ ]
112
+
113
+ [[package]]
114
+ name = "flask"
115
+ version = "3.1.1"
116
+ source = { registry = "https://pypi.org/simple" }
117
+ dependencies = [
118
+ { name = "blinker" },
119
+ { name = "click" },
120
+ { name = "itsdangerous" },
121
+ { name = "jinja2" },
122
+ { name = "markupsafe" },
123
+ { name = "werkzeug" },
124
+ ]
125
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440 }
126
+ wheels = [
127
+ { url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305 },
128
+ ]
129
+
130
+ [[package]]
131
+ name = "flask-dance"
132
+ version = "7.1.0"
133
+ source = { registry = "https://pypi.org/simple" }
134
+ dependencies = [
135
+ { name = "flask" },
136
+ { name = "oauthlib" },
137
+ { name = "requests" },
138
+ { name = "requests-oauthlib" },
139
+ { name = "urlobject" },
140
+ { name = "werkzeug" },
141
+ ]
142
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/b3/38aff96fbafe850f7f4186dc06e96ebc29625d68d1427ad65c9d41c4ec9e/flask_dance-7.1.0.tar.gz", hash = "sha256:6d0510e284f3d6ff05af918849791b17ef93a008628ec33f3a80578a44b51674", size = 140993 }
143
+ wheels = [
144
+ { url = "https://files.pythonhosted.org/packages/75/8c/4125e9f1196e5ab9675d38ff445ae4abd7085aba7551335980ac19196389/flask_dance-7.1.0-py3-none-any.whl", hash = "sha256:81599328a2b3604fd4332b3d41a901cf36980c2067e5e38c44ce3b85c4e1ae9c", size = 62176 },
145
+ ]
146
+
147
+ [[package]]
148
+ name = "flask-login"
149
+ version = "0.6.3"
150
+ source = { registry = "https://pypi.org/simple" }
151
+ dependencies = [
152
+ { name = "flask" },
153
+ { name = "werkzeug" },
154
+ ]
155
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/6e/2f4e13e373bb49e68c02c51ceadd22d172715a06716f9299d9df01b6ddb2/Flask-Login-0.6.3.tar.gz", hash = "sha256:5e23d14a607ef12806c699590b89d0f0e0d67baeec599d75947bf9c147330333", size = 48834 }
156
+ wheels = [
157
+ { url = "https://files.pythonhosted.org/packages/59/f5/67e9cc5c2036f58115f9fe0f00d203cf6780c3ff8ae0e705e7a9d9e8ff9e/Flask_Login-0.6.3-py3-none-any.whl", hash = "sha256:849b25b82a436bf830a054e74214074af59097171562ab10bfa999e6b78aae5d", size = 17303 },
158
+ ]
159
+
160
+ [[package]]
161
+ name = "flask-sqlalchemy"
162
+ version = "3.1.1"
163
+ source = { registry = "https://pypi.org/simple" }
164
+ dependencies = [
165
+ { name = "flask" },
166
+ { name = "sqlalchemy" },
167
+ ]
168
+ sdist = { url = "https://files.pythonhosted.org/packages/91/53/b0a9fcc1b1297f51e68b69ed3b7c3c40d8c45be1391d77ae198712914392/flask_sqlalchemy-3.1.1.tar.gz", hash = "sha256:e4b68bb881802dda1a7d878b2fc84c06d1ee57fb40b874d3dc97dabfa36b8312", size = 81899 }
169
+ wheels = [
170
+ { url = "https://files.pythonhosted.org/packages/1d/6a/89963a5c6ecf166e8be29e0d1bf6806051ee8fe6c82e232842e3aeac9204/flask_sqlalchemy-3.1.1-py3-none-any.whl", hash = "sha256:4ba4be7f419dc72f4efd8802d69974803c37259dd42f3913b0dcf75c9447e0a0", size = 25125 },
171
+ ]
172
+
173
+ [[package]]
174
+ name = "greenlet"
175
+ version = "3.2.3"
176
+ source = { registry = "https://pypi.org/simple" }
177
+ sdist = { url = "https://files.pythonhosted.org/packages/c9/92/bb85bd6e80148a4d2e0c59f7c0c2891029f8fd510183afc7d8d2feeed9b6/greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365", size = 185752 }
178
+ wheels = [
179
+ { 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 },
180
+ { 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 },
181
+ { 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 },
182
+ { 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 },
183
+ { 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 },
184
+ { 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 },
185
+ { 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 },
186
+ { 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 },
187
+ { url = "https://files.pythonhosted.org/packages/66/77/d48fb441b5a71125bcac042fc5b1494c806ccb9a1432ecaa421e72157f77/greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34", size = 297017 },
188
+ { 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 },
189
+ { 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 },
190
+ { 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 },
191
+ { 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 },
192
+ { 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 },
193
+ { 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 },
194
+ { 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 },
195
+ { 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 },
196
+ { url = "https://files.pythonhosted.org/packages/1f/8f/8f9e56c5e82eb2c26e8cde787962e66494312dc8cb261c460e1f3a9c88bc/greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849", size = 297817 },
197
+ { 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 },
198
+ { 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 },
199
+ { 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 },
200
+ { 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 },
201
+ { 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 },
202
+ { 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 },
203
+ { 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 },
204
+ { 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 },
205
+ { url = "https://files.pythonhosted.org/packages/27/1a/199f9587e8cb08a0658f9c30f3799244307614148ffe8b1e3aa22f324dea/greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3", size = 297603 },
206
+ { 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 },
207
+ { 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 },
208
+ { 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 },
209
+ { 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 },
210
+ { 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 },
211
+ { 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 },
212
+ { url = "https://files.pythonhosted.org/packages/5c/4f/aab73ecaa6b3086a4c89863d94cf26fa84cbff63f52ce9bc4342b3087a06/greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a", size = 301236 },
213
+ ]
214
+
215
+ [[package]]
216
+ name = "gunicorn"
217
+ version = "23.0.0"
218
+ source = { registry = "https://pypi.org/simple" }
219
+ dependencies = [
220
+ { name = "packaging" },
221
+ ]
222
+ sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031 }
223
+ wheels = [
224
+ { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029 },
225
+ ]
226
+
227
+ [[package]]
228
+ name = "idna"
229
+ version = "3.10"
230
+ source = { registry = "https://pypi.org/simple" }
231
+ sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
232
+ wheels = [
233
+ { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
234
+ ]
235
+
236
+ [[package]]
237
+ name = "itsdangerous"
238
+ version = "2.2.0"
239
+ source = { registry = "https://pypi.org/simple" }
240
+ sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 }
241
+ wheels = [
242
+ { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 },
243
+ ]
244
+
245
+ [[package]]
246
+ name = "jinja2"
247
+ version = "3.1.6"
248
+ source = { registry = "https://pypi.org/simple" }
249
+ dependencies = [
250
+ { name = "markupsafe" },
251
+ ]
252
+ sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
253
+ wheels = [
254
+ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
255
+ ]
256
+
257
+ [[package]]
258
+ name = "markupsafe"
259
+ version = "3.0.2"
260
+ source = { registry = "https://pypi.org/simple" }
261
+ sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
262
+ wheels = [
263
+ { 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 },
264
+ { 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 },
265
+ { 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 },
266
+ { 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 },
267
+ { 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 },
268
+ { 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 },
269
+ { 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 },
270
+ { 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 },
271
+ { url = "https://files.pythonhosted.org/packages/9a/34/a15aa69f01e2181ed8d2b685c0d2f6655d5cca2c4db0ddea775e631918cd/MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d", size = 15094 },
272
+ { url = "https://files.pythonhosted.org/packages/da/b8/3a3bd761922d416f3dc5d00bfbed11f66b1ab89a0c2b6e887240a30b0f6b/MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b", size = 15521 },
273
+ { 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 },
274
+ { 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 },
275
+ { 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 },
276
+ { 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 },
277
+ { 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 },
278
+ { 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 },
279
+ { 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 },
280
+ { 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 },
281
+ { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097 },
282
+ { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601 },
283
+ { 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 },
284
+ { 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 },
285
+ { 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 },
286
+ { 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 },
287
+ { 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 },
288
+ { 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 },
289
+ { 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 },
290
+ { 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 },
291
+ { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101 },
292
+ { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603 },
293
+ { 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 },
294
+ { 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 },
295
+ { 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 },
296
+ { 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 },
297
+ { 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 },
298
+ { 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 },
299
+ { 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 },
300
+ { 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 },
301
+ { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208 },
302
+ { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739 },
303
+ ]
304
+
305
+ [[package]]
306
+ name = "oauthlib"
307
+ version = "3.3.1"
308
+ source = { registry = "https://pypi.org/simple" }
309
+ sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918 }
310
+ wheels = [
311
+ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065 },
312
+ ]
313
+
314
+ [[package]]
315
+ name = "packaging"
316
+ version = "25.0"
317
+ source = { registry = "https://pypi.org/simple" }
318
+ sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727 }
319
+ wheels = [
320
+ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 },
321
+ ]
322
+
323
+ [[package]]
324
+ name = "psycopg2-binary"
325
+ version = "2.9.10"
326
+ source = { registry = "https://pypi.org/simple" }
327
+ sdist = { url = "https://files.pythonhosted.org/packages/cb/0e/bdc8274dc0585090b4e3432267d7be4dfbfd8971c0fa59167c711105a6bf/psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2", size = 385764 }
328
+ wheels = [
329
+ { 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 },
330
+ { 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 },
331
+ { 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 },
332
+ { 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 },
333
+ { 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 },
334
+ { 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 },
335
+ { 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 },
336
+ { 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 },
337
+ { 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 },
338
+ { 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 },
339
+ { url = "https://files.pythonhosted.org/packages/e2/6b/144336a9bf08a67d217b3af3246abb1d027095dab726f0687f01f43e8c03/psycopg2_binary-2.9.10-cp311-cp311-win32.whl", hash = "sha256:ecced182e935529727401b24d76634a357c71c9275b356efafd8a2a91ec07392", size = 1024618 },
340
+ { url = "https://files.pythonhosted.org/packages/61/69/3b3d7bd583c6d3cbe5100802efa5beacaacc86e37b653fc708bf3d6853b8/psycopg2_binary-2.9.10-cp311-cp311-win_amd64.whl", hash = "sha256:ee0e8c683a7ff25d23b55b11161c2663d4b099770f6085ff0a20d4505778d6b4", size = 1163816 },
341
+ { 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 },
342
+ { 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 },
343
+ { 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 },
344
+ { 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 },
345
+ { 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 },
346
+ { 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 },
347
+ { 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 },
348
+ { 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 },
349
+ { 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 },
350
+ { 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 },
351
+ { url = "https://files.pythonhosted.org/packages/d3/bd/213e59854fafe87ba47814bf413ace0dcee33a89c8c8c814faca6bc7cf3c/psycopg2_binary-2.9.10-cp312-cp312-win32.whl", hash = "sha256:ec8a77f521a17506a24a5f626cb2aee7850f9b69a0afe704586f63a464f3cd64", size = 1025191 },
352
+ { url = "https://files.pythonhosted.org/packages/92/29/06261ea000e2dc1e22907dbbc483a1093665509ea586b29b8986a0e56733/psycopg2_binary-2.9.10-cp312-cp312-win_amd64.whl", hash = "sha256:18c5ee682b9c6dd3696dad6e54cc7ff3a1a9020df6a5c0f861ef8bfd338c3ca0", size = 1164031 },
353
+ { 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 },
354
+ { 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 },
355
+ { 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 },
356
+ { 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 },
357
+ { 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 },
358
+ { 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 },
359
+ { 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 },
360
+ { 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 },
361
+ { 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 },
362
+ { 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 },
363
+ { url = "https://files.pythonhosted.org/packages/08/50/d13ea0a054189ae1bc21af1d85b6f8bb9bbc5572991055d70ad9006fe2d6/psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142", size = 2569224 },
364
+ ]
365
+
366
+ [[package]]
367
+ name = "pyjwt"
368
+ version = "2.10.1"
369
+ source = { registry = "https://pypi.org/simple" }
370
+ sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
371
+ wheels = [
372
+ { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
373
+ ]
374
+
375
+ [[package]]
376
+ name = "repl-nix-workspace"
377
+ version = "0.1.0"
378
+ source = { virtual = "." }
379
+ dependencies = [
380
+ { name = "email-validator" },
381
+ { name = "flask" },
382
+ { name = "flask-dance" },
383
+ { name = "flask-login" },
384
+ { name = "flask-sqlalchemy" },
385
+ { name = "gunicorn" },
386
+ { name = "oauthlib" },
387
+ { name = "psycopg2-binary" },
388
+ { name = "pyjwt" },
389
+ { name = "sqlalchemy" },
390
+ { name = "werkzeug" },
391
+ ]
392
+
393
+ [package.metadata]
394
+ requires-dist = [
395
+ { name = "email-validator", specifier = ">=2.2.0" },
396
+ { name = "flask", specifier = ">=3.1.1" },
397
+ { name = "flask-dance", specifier = ">=7.1.0" },
398
+ { name = "flask-login", specifier = ">=0.6.3" },
399
+ { name = "flask-sqlalchemy", specifier = ">=3.1.1" },
400
+ { name = "gunicorn", specifier = ">=23.0.0" },
401
+ { name = "oauthlib", specifier = ">=3.3.1" },
402
+ { name = "psycopg2-binary", specifier = ">=2.9.10" },
403
+ { name = "pyjwt", specifier = ">=2.10.1" },
404
+ { name = "sqlalchemy", specifier = ">=2.0.41" },
405
+ { name = "werkzeug", specifier = ">=3.1.3" },
406
+ ]
407
+
408
+ [[package]]
409
+ name = "requests"
410
+ version = "2.32.4"
411
+ source = { registry = "https://pypi.org/simple" }
412
+ dependencies = [
413
+ { name = "certifi" },
414
+ { name = "charset-normalizer" },
415
+ { name = "idna" },
416
+ { name = "urllib3" },
417
+ ]
418
+ sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 }
419
+ wheels = [
420
+ { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 },
421
+ ]
422
+
423
+ [[package]]
424
+ name = "requests-oauthlib"
425
+ version = "2.0.0"
426
+ source = { registry = "https://pypi.org/simple" }
427
+ dependencies = [
428
+ { name = "oauthlib" },
429
+ { name = "requests" },
430
+ ]
431
+ sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650 }
432
+ wheels = [
433
+ { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179 },
434
+ ]
435
+
436
+ [[package]]
437
+ name = "sqlalchemy"
438
+ version = "2.0.41"
439
+ source = { registry = "https://pypi.org/simple" }
440
+ dependencies = [
441
+ { 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')" },
442
+ { name = "typing-extensions" },
443
+ ]
444
+ sdist = { url = "https://files.pythonhosted.org/packages/63/66/45b165c595ec89aa7dcc2c1cd222ab269bc753f1fc7a1e68f8481bd957bf/sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9", size = 9689424 }
445
+ wheels = [
446
+ { 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 },
447
+ { 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 },
448
+ { 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 },
449
+ { 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 },
450
+ { 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 },
451
+ { 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 },
452
+ { url = "https://files.pythonhosted.org/packages/04/61/c0d4607f7799efa8b8ea3c49b4621e861c8f5c41fd4b5b636c534fcb7d73/sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8", size = 2086475 },
453
+ { url = "https://files.pythonhosted.org/packages/9d/8e/8344f8ae1cb6a479d0741c02cd4f666925b2bf02e2468ddaf5ce44111f30/sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504", size = 2110903 },
454
+ { 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 },
455
+ { 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 },
456
+ { 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 },
457
+ { 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 },
458
+ { 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 },
459
+ { 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 },
460
+ { url = "https://files.pythonhosted.org/packages/5c/ca/0c19ec16858585d37767b167fc9602593f98998a68a798450558239fb04a/sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6", size = 2084514 },
461
+ { url = "https://files.pythonhosted.org/packages/7f/23/4c2833d78ff3010a4e17f984c734f52b531a8c9060a50429c9d4b0211be6/sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0", size = 2111557 },
462
+ { 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 },
463
+ { 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 },
464
+ { 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 },
465
+ { 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 },
466
+ { 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 },
467
+ { 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 },
468
+ { url = "https://files.pythonhosted.org/packages/fa/2e/677c17c5d6a004c3c45334ab1dbe7b7deb834430b282b8a0f75ae220c8eb/sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f", size = 2082420 },
469
+ { url = "https://files.pythonhosted.org/packages/e9/61/e8c1b9b6307c57157d328dd8b8348ddc4c47ffdf1279365a13b2b98b8049/sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df", size = 2108329 },
470
+ { url = "https://files.pythonhosted.org/packages/1c/fc/9ba22f01b5cdacc8f5ed0d22304718d2c758fce3fd49a5372b886a86f37c/sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576", size = 1911224 },
471
+ ]
472
+
473
+ [[package]]
474
+ name = "typing-extensions"
475
+ version = "4.14.1"
476
+ source = { registry = "https://pypi.org/simple" }
477
+ sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673 }
478
+ wheels = [
479
+ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906 },
480
+ ]
481
+
482
+ [[package]]
483
+ name = "urllib3"
484
+ version = "2.5.0"
485
+ source = { registry = "https://pypi.org/simple" }
486
+ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 }
487
+ wheels = [
488
+ { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 },
489
+ ]
490
+
491
+ [[package]]
492
+ name = "urlobject"
493
+ version = "3.0.0"
494
+ source = { registry = "https://pypi.org/simple" }
495
+ sdist = { url = "https://files.pythonhosted.org/packages/2a/fd/163e6b835b9fabf9c3999f71c5f224daa9d68a38012cccd7ab2a2f861af9/urlobject-3.0.0.tar.gz", hash = "sha256:bfdfe70746d92a039a33e964959bb12cecd9807a434fdb7fef5f38e70a295818", size = 28237 }
496
+ wheels = [
497
+ { url = "https://files.pythonhosted.org/packages/ee/38/18c4bbe751a7357b3f6a33352e3af3305ad78f3e72ab7e3d667de4663ed9/urlobject-3.0.0-py3-none-any.whl", hash = "sha256:fd2465520d0a8c5ed983aa47518a2c5bcde0c276a4fd0eb28b0de5dcefd93b1e", size = 16261 },
498
+ ]
499
+
500
+ [[package]]
501
+ name = "werkzeug"
502
+ version = "3.1.3"
503
+ source = { registry = "https://pypi.org/simple" }
504
+ dependencies = [
505
+ { name = "markupsafe" },
506
+ ]
507
+ sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925 }
508
+ wheels = [
509
+ { url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498 },
510
+ ]