Spaces:
Sleeping
Sleeping
Upload 18 files
Browse files- .replit +34 -0
- app.py +56 -0
- instance/pyproject.toml +18 -0
- main.py +4 -0
- models.py +85 -0
- replit.md +99 -0
- routes.py +762 -0
- static/css/style.css +946 -0
- static/images/logo.svg +86 -0
- static/js/audio.js +414 -0
- static/js/chat.js +1016 -0
- static/js/files.js +433 -0
- static/js/main.js +242 -0
- templates/chat.html +264 -0
- templates/landing.html +94 -0
- templates/register.html +116 -0
- templates/settings.html +155 -0
- uv.lock +510 -0
.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, "&")
|
32 |
+
.replace(/</g, "<")
|
33 |
+
.replace(/>/g, ">")
|
34 |
+
.replace(/"/g, """)
|
35 |
+
.replace(/'/g, "'");
|
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 |
+
]
|