Added messaging functions
This commit is contained in:
Binary file not shown.
BIN
__pycache__/extensions.cpython-313.pyc
Normal file
BIN
__pycache__/extensions.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
13
app.py
13
app.py
@@ -1,10 +1,9 @@
|
|||||||
from flask import Flask, send_from_directory
|
from flask import Flask, send_from_directory
|
||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_login import LoginManager
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
import os
|
import os
|
||||||
from models import db, User
|
from models import User
|
||||||
from flask_wtf.csrf import CSRFProtect, generate_csrf
|
from flask_wtf.csrf import generate_csrf
|
||||||
from routes.room_files import room_files_bp
|
from routes.room_files import room_files_bp
|
||||||
from routes.user import user_bp
|
from routes.user import user_bp
|
||||||
from routes.room_members import room_members_bp
|
from routes.room_members import room_members_bp
|
||||||
@@ -12,6 +11,7 @@ from routes.trash import trash_bp
|
|||||||
from tasks import cleanup_trash
|
from tasks import cleanup_trash
|
||||||
import click
|
import click
|
||||||
from utils import timeago
|
from utils import timeago
|
||||||
|
from extensions import db, login_manager, csrf, socketio
|
||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
@@ -28,9 +28,10 @@ def create_app():
|
|||||||
# Initialize extensions
|
# Initialize extensions
|
||||||
db.init_app(app)
|
db.init_app(app)
|
||||||
migrate = Migrate(app, db)
|
migrate = Migrate(app, db)
|
||||||
login_manager = LoginManager(app)
|
login_manager.init_app(app)
|
||||||
login_manager.login_view = 'auth.login'
|
login_manager.login_view = 'auth.login'
|
||||||
csrf = CSRFProtect(app)
|
csrf.init_app(app)
|
||||||
|
socketio.init_app(app)
|
||||||
|
|
||||||
@app.context_processor
|
@app.context_processor
|
||||||
def inject_csrf_token():
|
def inject_csrf_token():
|
||||||
@@ -67,4 +68,4 @@ def profile_pic(filename):
|
|||||||
return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename)
|
return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True)
|
socketio.run(app, debug=True)
|
||||||
@@ -4,36 +4,25 @@ services:
|
|||||||
web:
|
web:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "10334:5000"
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./uploads:/app/uploads
|
||||||
environment:
|
environment:
|
||||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/flask_app
|
|
||||||
- FLASK_APP=app.py
|
- FLASK_APP=app.py
|
||||||
- FLASK_ENV=development
|
- FLASK_ENV=development
|
||||||
|
- UPLOAD_FOLDER=/app/uploads
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
volumes:
|
|
||||||
- .:/app
|
|
||||||
- room_data:/data
|
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgres:13
|
||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=postgres
|
- POSTGRES_USER=postgres
|
||||||
- POSTGRES_PASSWORD=postgres
|
- POSTGRES_PASSWORD=postgres
|
||||||
- POSTGRES_DB=flask_app
|
- POSTGRES_DB=docupulse
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
|
||||||
- app-network
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
room_data:
|
uploads:
|
||||||
|
|
||||||
networks:
|
|
||||||
app-network:
|
|
||||||
driver: bridge
|
|
||||||
10
extensions.py
Normal file
10
extensions.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from flask_socketio import SocketIO
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_login import LoginManager
|
||||||
|
from flask_wtf.csrf import CSRFProtect
|
||||||
|
|
||||||
|
# Initialize extensions
|
||||||
|
db = SQLAlchemy()
|
||||||
|
login_manager = LoginManager()
|
||||||
|
csrf = CSRFProtect()
|
||||||
|
socketio = SocketIO(cors_allowed_origins="*")
|
||||||
12
forms.py
12
forms.py
@@ -1,5 +1,5 @@
|
|||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField
|
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField, SelectMultipleField
|
||||||
from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError
|
from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError
|
||||||
from models import User
|
from models import User
|
||||||
from flask_login import current_user
|
from flask_login import current_user
|
||||||
@@ -48,3 +48,13 @@ class RoomForm(FlaskForm):
|
|||||||
name = StringField('Room Name', validators=[DataRequired(), Length(min=3, max=100)])
|
name = StringField('Room Name', validators=[DataRequired(), Length(min=3, max=100)])
|
||||||
description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
|
description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
|
||||||
submit = SubmitField('Create Room')
|
submit = SubmitField('Create Room')
|
||||||
|
|
||||||
|
class ConversationForm(FlaskForm):
|
||||||
|
name = StringField('Name', validators=[DataRequired(), Length(min=1, max=100)])
|
||||||
|
description = TextAreaField('Description')
|
||||||
|
members = SelectMultipleField('Members', coerce=int)
|
||||||
|
submit = SubmitField('Create Conversation')
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super(ConversationForm, self).__init__(*args, **kwargs)
|
||||||
|
self.members.choices = [(u.id, f"{u.username} {u.last_name}") for u in User.query.filter_by(is_active=True).all()]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""add file attachment fields to message model
|
||||||
|
|
||||||
|
Revision ID: 0f48943140fa
|
||||||
|
Revises: bd04430cda95
|
||||||
|
Create Date: 2025-05-26 14:00:05.521776
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0f48943140fa'
|
||||||
|
down_revision = 'bd04430cda95'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('message', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('has_attachment', sa.Boolean(), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('attachment_name', sa.String(length=255), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('attachment_path', sa.String(length=512), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('attachment_type', sa.String(length=100), nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('attachment_size', sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('message', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('attachment_size')
|
||||||
|
batch_op.drop_column('attachment_type')
|
||||||
|
batch_op.drop_column('attachment_path')
|
||||||
|
batch_op.drop_column('attachment_name')
|
||||||
|
batch_op.drop_column('has_attachment')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
57
migrations/versions/add_conversations_tables.py
Normal file
57
migrations/versions/add_conversations_tables.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
"""Add conversations and messages tables
|
||||||
|
|
||||||
|
Revision ID: add_conversations_tables
|
||||||
|
Revises: 2c5f57dddb78
|
||||||
|
Create Date: 2024-03-19 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'add_conversations_tables'
|
||||||
|
down_revision = '2c5f57dddb78'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Create conversation table first
|
||||||
|
op.create_table('conversation',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=100), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create conversation_members table
|
||||||
|
op.create_table('conversation_members',
|
||||||
|
sa.Column('conversation_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('conversation_id', 'user_id')
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create message table
|
||||||
|
op.create_table('message',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('content', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.Column('conversation_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['conversation_id'], ['conversation.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# Drop tables in reverse order
|
||||||
|
op.drop_table('message')
|
||||||
|
op.drop_table('conversation_members')
|
||||||
|
op.drop_table('conversation')
|
||||||
24
migrations/versions/bd04430cda95_merge_heads.py
Normal file
24
migrations/versions/bd04430cda95_merge_heads.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
"""merge heads
|
||||||
|
|
||||||
|
Revision ID: bd04430cda95
|
||||||
|
Revises: f18735338888, add_conversations_tables
|
||||||
|
Create Date: 2025-05-26 11:14:05.629795
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'bd04430cda95'
|
||||||
|
down_revision = ('f18735338888', 'add_conversations_tables')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"""add message attachments table and update message model
|
||||||
|
|
||||||
|
Revision ID: e7e4ff171f7a
|
||||||
|
Revises: 0f48943140fa
|
||||||
|
Create Date: 2025-05-26 15:00:18.557702
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'e7e4ff171f7a'
|
||||||
|
down_revision = '0f48943140fa'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('message_attachment',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('message_id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('path', sa.String(length=512), nullable=False),
|
||||||
|
sa.Column('type', sa.String(length=100), nullable=True),
|
||||||
|
sa.Column('size', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['message_id'], ['message.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
with op.batch_alter_table('message', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('attachment_path')
|
||||||
|
batch_op.drop_column('attachment_type')
|
||||||
|
batch_op.drop_column('has_attachment')
|
||||||
|
batch_op.drop_column('attachment_size')
|
||||||
|
batch_op.drop_column('attachment_name')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('message', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('attachment_name', sa.VARCHAR(length=255), autoincrement=False, nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('attachment_size', sa.INTEGER(), autoincrement=False, nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('has_attachment', sa.BOOLEAN(), autoincrement=False, nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('attachment_type', sa.VARCHAR(length=100), autoincrement=False, nullable=True))
|
||||||
|
batch_op.add_column(sa.Column('attachment_path', sa.VARCHAR(length=512), autoincrement=False, nullable=True))
|
||||||
|
|
||||||
|
op.drop_table('message_attachment')
|
||||||
|
# ### end Alembic commands ###
|
||||||
54
models.py
54
models.py
@@ -3,8 +3,7 @@ from flask_login import UserMixin
|
|||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
|
from extensions import db
|
||||||
db = SQLAlchemy()
|
|
||||||
|
|
||||||
# Association table for room members
|
# Association table for room members
|
||||||
room_members = db.Table('room_members',
|
room_members = db.Table('room_members',
|
||||||
@@ -12,6 +11,12 @@ room_members = db.Table('room_members',
|
|||||||
db.Column('user_id', db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
db.Column('user_id', db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Association table for conversation members
|
||||||
|
conversation_members = db.Table('conversation_members',
|
||||||
|
db.Column('conversation_id', db.Integer, db.ForeignKey('conversation.id'), primary_key=True),
|
||||||
|
db.Column('user_id', db.Integer, db.ForeignKey('user.id'), primary_key=True)
|
||||||
|
)
|
||||||
|
|
||||||
class User(UserMixin, db.Model):
|
class User(UserMixin, db.Model):
|
||||||
id = db.Column(db.Integer, primary_key=True)
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
username = db.Column(db.String(150), unique=True, nullable=False)
|
username = db.Column(db.String(150), unique=True, nullable=False)
|
||||||
@@ -152,3 +157,48 @@ class SiteSettings(db.Model):
|
|||||||
db.session.add(settings)
|
db.session.add(settings)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return settings
|
return settings
|
||||||
|
|
||||||
|
class Conversation(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
name = db.Column(db.String(100), nullable=False)
|
||||||
|
description = db.Column(db.Text)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
creator = db.relationship('User', backref='created_conversations', foreign_keys=[created_by])
|
||||||
|
members = db.relationship('User', secondary=conversation_members, backref=db.backref('conversations', lazy='dynamic'))
|
||||||
|
messages = db.relationship('Message', back_populates='conversation', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Conversation {self.name}>'
|
||||||
|
|
||||||
|
class Message(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
content = db.Column(db.Text, nullable=False)
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
conversation_id = db.Column(db.Integer, db.ForeignKey('conversation.id'), nullable=False)
|
||||||
|
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
conversation = db.relationship('Conversation', back_populates='messages')
|
||||||
|
user = db.relationship('User', backref='messages')
|
||||||
|
attachments = db.relationship('MessageAttachment', back_populates='message', cascade='all, delete-orphan')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<Message {self.id}>'
|
||||||
|
|
||||||
|
class MessageAttachment(db.Model):
|
||||||
|
id = db.Column(db.Integer, primary_key=True)
|
||||||
|
message_id = db.Column(db.Integer, db.ForeignKey('message.id'), nullable=False)
|
||||||
|
name = db.Column(db.String(255), nullable=False)
|
||||||
|
path = db.Column(db.String(512), nullable=False)
|
||||||
|
type = db.Column(db.String(100))
|
||||||
|
size = db.Column(db.Integer) # Size in bytes
|
||||||
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
message = db.relationship('Message', back_populates='attachments')
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f'<MessageAttachment {self.name}>'
|
||||||
@@ -9,3 +9,4 @@ WTForms==3.1.1
|
|||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
psycopg2-binary==2.9.9
|
psycopg2-binary==2.9.9
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
|
Flask-SocketIO==5.3.6
|
||||||
@@ -13,6 +13,7 @@ def init_app(app: Flask):
|
|||||||
from .auth import init_routes as init_auth_routes
|
from .auth import init_routes as init_auth_routes
|
||||||
from .contacts import contacts_bp as contacts_routes
|
from .contacts import contacts_bp as contacts_routes
|
||||||
from .rooms import rooms_bp as rooms_routes
|
from .rooms import rooms_bp as rooms_routes
|
||||||
|
from .conversations import conversations_bp as conversations_routes
|
||||||
|
|
||||||
# Initialize routes
|
# Initialize routes
|
||||||
init_main_routes(main_bp)
|
init_main_routes(main_bp)
|
||||||
@@ -29,6 +30,7 @@ def init_app(app: Flask):
|
|||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(rooms_routes)
|
app.register_blueprint(rooms_routes)
|
||||||
app.register_blueprint(contacts_routes)
|
app.register_blueprint(contacts_routes)
|
||||||
|
app.register_blueprint(conversations_routes)
|
||||||
|
|
||||||
@app.route('/rooms/<int:room_id>/trash')
|
@app.route('/rooms/<int:room_id>/trash')
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
Binary file not shown.
BIN
routes/__pycache__/conversations.cpython-313.pyc
Normal file
BIN
routes/__pycache__/conversations.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
390
routes/conversations.py
Normal file
390
routes/conversations.py
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
from flask import Blueprint, render_template, redirect, url_for, flash, request, jsonify, send_file
|
||||||
|
from flask_login import login_required, current_user
|
||||||
|
from flask_socketio import emit, join_room, leave_room
|
||||||
|
from models import db, Conversation, User, Message, MessageAttachment
|
||||||
|
from forms import ConversationForm
|
||||||
|
import os
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from datetime import datetime
|
||||||
|
from extensions import socketio
|
||||||
|
|
||||||
|
conversations_bp = Blueprint('conversations', __name__, url_prefix='/conversations')
|
||||||
|
|
||||||
|
# Configure upload settings
|
||||||
|
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER', 'uploads')
|
||||||
|
ALLOWED_EXTENSIONS = {
|
||||||
|
# Documents
|
||||||
|
'pdf', 'docx', 'doc', 'txt', 'rtf', 'odt', 'md', 'csv',
|
||||||
|
# Spreadsheets
|
||||||
|
'xlsx', 'xls', 'ods', 'xlsm',
|
||||||
|
# Presentations
|
||||||
|
'pptx', 'ppt', 'odp',
|
||||||
|
# Images
|
||||||
|
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'tiff',
|
||||||
|
# Archives
|
||||||
|
'zip', 'rar', '7z', 'tar', 'gz',
|
||||||
|
# Code/Text
|
||||||
|
'py', 'js', 'html', 'css', 'json', 'xml', 'sql', 'sh', 'bat',
|
||||||
|
# Audio
|
||||||
|
'mp3', 'wav', 'ogg', 'm4a', 'flac',
|
||||||
|
# Video
|
||||||
|
'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm',
|
||||||
|
# CAD/Design
|
||||||
|
'dwg', 'dxf', 'ai', 'psd', 'eps', 'indd',
|
||||||
|
# Other
|
||||||
|
'eml', 'msg', 'vcf', 'ics'
|
||||||
|
}
|
||||||
|
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
||||||
|
|
||||||
|
def allowed_file(filename):
|
||||||
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
def get_file_extension(filename):
|
||||||
|
return filename.rsplit('.', 1)[1].lower() if '.' in filename else ''
|
||||||
|
|
||||||
|
@conversations_bp.route('/')
|
||||||
|
@login_required
|
||||||
|
def conversations():
|
||||||
|
search = request.args.get('search', '').strip()
|
||||||
|
if current_user.is_admin:
|
||||||
|
query = Conversation.query
|
||||||
|
else:
|
||||||
|
query = Conversation.query.filter(Conversation.members.any(id=current_user.id))
|
||||||
|
if search:
|
||||||
|
query = query.filter(Conversation.name.ilike(f'%{search}%'))
|
||||||
|
conversations = query.order_by(Conversation.created_at.desc()).all()
|
||||||
|
return render_template('conversations/conversations.html', conversations=conversations, search=search)
|
||||||
|
|
||||||
|
@conversations_bp.route('/create', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def create_conversation():
|
||||||
|
if not current_user.is_admin:
|
||||||
|
flash('Only administrators can create conversations.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
|
||||||
|
form = ConversationForm()
|
||||||
|
if form.validate_on_submit():
|
||||||
|
conversation = Conversation(
|
||||||
|
name=form.name.data,
|
||||||
|
description=form.description.data,
|
||||||
|
created_by=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add creator as a member
|
||||||
|
conversation.members.append(current_user)
|
||||||
|
creator_id = current_user.id
|
||||||
|
# Add selected members, skipping the creator if present
|
||||||
|
for user_id in form.members.data:
|
||||||
|
if int(user_id) != creator_id:
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if user and user not in conversation.members:
|
||||||
|
conversation.members.append(user)
|
||||||
|
|
||||||
|
db.session.add(conversation)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Conversation created successfully!', 'success')
|
||||||
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
return render_template('conversations/create_conversation.html', form=form)
|
||||||
|
|
||||||
|
@conversations_bp.route('/<int:conversation_id>')
|
||||||
|
@login_required
|
||||||
|
def conversation(conversation_id):
|
||||||
|
conversation = Conversation.query.get_or_404(conversation_id)
|
||||||
|
# Check if user is a member
|
||||||
|
if not current_user.is_admin and current_user not in conversation.members:
|
||||||
|
flash('You do not have access to this conversation.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
|
||||||
|
# Query messages directly using the Message model
|
||||||
|
messages = Message.query.filter_by(conversation_id=conversation_id).order_by(Message.created_at.asc()).all()
|
||||||
|
|
||||||
|
# Get all users for member selection (only needed for admin)
|
||||||
|
all_users = User.query.all() if current_user.is_admin else None
|
||||||
|
|
||||||
|
return render_template('conversations/conversation.html',
|
||||||
|
conversation=conversation,
|
||||||
|
messages=messages,
|
||||||
|
all_users=all_users)
|
||||||
|
|
||||||
|
@conversations_bp.route('/<int:conversation_id>/members')
|
||||||
|
@login_required
|
||||||
|
def conversation_members(conversation_id):
|
||||||
|
conversation = Conversation.query.get_or_404(conversation_id)
|
||||||
|
if not current_user.is_admin and current_user not in conversation.members:
|
||||||
|
flash('You do not have access to this conversation.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
|
||||||
|
if not current_user.is_admin:
|
||||||
|
flash('Only administrators can manage conversation members.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversation', conversation_id=conversation_id))
|
||||||
|
|
||||||
|
available_users = User.query.filter(~User.id.in_([m.id for m in conversation.members])).all()
|
||||||
|
return render_template('conversations/conversation_members.html',
|
||||||
|
conversation=conversation,
|
||||||
|
available_users=available_users)
|
||||||
|
|
||||||
|
@conversations_bp.route('/<int:conversation_id>/members/add', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def add_member(conversation_id):
|
||||||
|
conversation = Conversation.query.get_or_404(conversation_id)
|
||||||
|
if not current_user.is_admin:
|
||||||
|
flash('Only administrators can manage conversation members.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversation', conversation_id=conversation_id))
|
||||||
|
|
||||||
|
user_id = request.form.get('user_id')
|
||||||
|
if not user_id:
|
||||||
|
flash('Please select a user to add.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversation_members', conversation_id=conversation_id))
|
||||||
|
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
if user in conversation.members:
|
||||||
|
flash('User is already a member of this conversation.', 'error')
|
||||||
|
else:
|
||||||
|
conversation.members.append(user)
|
||||||
|
db.session.commit()
|
||||||
|
flash(f'{user.username} has been added to the conversation.', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('conversations.conversation_members', conversation_id=conversation_id))
|
||||||
|
|
||||||
|
@conversations_bp.route('/<int:conversation_id>/members/<int:user_id>/remove', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def remove_member(conversation_id, user_id):
|
||||||
|
conversation = Conversation.query.get_or_404(conversation_id)
|
||||||
|
if not current_user.is_admin:
|
||||||
|
flash('Only administrators can manage conversation members.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversation', conversation_id=conversation_id))
|
||||||
|
|
||||||
|
if user_id == conversation.created_by:
|
||||||
|
flash('Cannot remove the conversation creator.', 'error')
|
||||||
|
else:
|
||||||
|
user = User.query.get_or_404(user_id)
|
||||||
|
if user not in conversation.members:
|
||||||
|
flash('User is not a member of this conversation.', 'error')
|
||||||
|
else:
|
||||||
|
conversation.members.remove(user)
|
||||||
|
db.session.commit()
|
||||||
|
flash('User has been removed from the conversation.', 'success')
|
||||||
|
|
||||||
|
return redirect(url_for('conversations.conversation_members', conversation_id=conversation_id))
|
||||||
|
|
||||||
|
@conversations_bp.route('/<int:conversation_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit_conversation(conversation_id):
|
||||||
|
if not current_user.is_admin:
|
||||||
|
flash('Only administrators can edit conversations.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
conversation = Conversation.query.get_or_404(conversation_id)
|
||||||
|
form = ConversationForm(obj=conversation)
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
# Get members from the form data
|
||||||
|
member_ids = request.form.getlist('members')
|
||||||
|
|
||||||
|
# Update members
|
||||||
|
current_member_ids = {str(user.id) for user in conversation.members}
|
||||||
|
new_member_ids = set(member_ids)
|
||||||
|
|
||||||
|
# Remove members that are no longer in the list
|
||||||
|
for member_id in current_member_ids - new_member_ids:
|
||||||
|
if int(member_id) != conversation.created_by: # Don't remove the creator
|
||||||
|
user = User.query.get(member_id)
|
||||||
|
if user:
|
||||||
|
conversation.members.remove(user)
|
||||||
|
|
||||||
|
# Add new members
|
||||||
|
for member_id in new_member_ids - current_member_ids:
|
||||||
|
user = User.query.get(member_id)
|
||||||
|
if user and user not in conversation.members:
|
||||||
|
conversation.members.append(user)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
flash('Conversation members updated successfully!', 'success')
|
||||||
|
|
||||||
|
# Check if redirect parameter is provided
|
||||||
|
redirect_url = request.args.get('redirect')
|
||||||
|
if redirect_url:
|
||||||
|
return redirect(redirect_url)
|
||||||
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
|
||||||
|
# Prepopulate form members with current members
|
||||||
|
form.members.data = [str(user.id) for user in conversation.members]
|
||||||
|
return render_template('conversations/create_conversation.html', form=form, edit_mode=True, conversation=conversation)
|
||||||
|
|
||||||
|
@conversations_bp.route('/<int:conversation_id>/delete', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def delete_conversation(conversation_id):
|
||||||
|
if not current_user.is_admin:
|
||||||
|
flash('Only administrators can delete conversations.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
|
||||||
|
conversation = Conversation.query.get_or_404(conversation_id)
|
||||||
|
|
||||||
|
# Delete all messages in the conversation
|
||||||
|
Message.query.filter_by(conversation_id=conversation_id).delete()
|
||||||
|
|
||||||
|
# Delete the conversation
|
||||||
|
db.session.delete(conversation)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
flash('Conversation has been deleted successfully.', 'success')
|
||||||
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
|
||||||
|
@socketio.on('join_conversation')
|
||||||
|
@login_required
|
||||||
|
def on_join(data):
|
||||||
|
conversation_id = data.get('conversation_id')
|
||||||
|
conversation = Conversation.query.get_or_404(conversation_id)
|
||||||
|
|
||||||
|
# Check if user is a member
|
||||||
|
if not current_user.is_admin and current_user not in conversation.members:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Join the room
|
||||||
|
join_room(f'conversation_{conversation_id}')
|
||||||
|
|
||||||
|
@socketio.on('leave_conversation')
|
||||||
|
@login_required
|
||||||
|
def on_leave(data):
|
||||||
|
conversation_id = data.get('conversation_id')
|
||||||
|
leave_room(f'conversation_{conversation_id}')
|
||||||
|
|
||||||
|
@conversations_bp.route('/<int:conversation_id>/send_message', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def send_message(conversation_id):
|
||||||
|
conversation = Conversation.query.get_or_404(conversation_id)
|
||||||
|
|
||||||
|
# Check if user is a member
|
||||||
|
if not current_user.is_admin and current_user not in conversation.members:
|
||||||
|
return jsonify({'success': False, 'error': 'You do not have access to this conversation.'}), 403
|
||||||
|
|
||||||
|
message_content = request.form.get('message', '').strip()
|
||||||
|
file_count = int(request.form.get('file_count', 0))
|
||||||
|
|
||||||
|
if not message_content and file_count == 0:
|
||||||
|
return jsonify({'success': False, 'error': 'Message or file is required.'}), 400
|
||||||
|
|
||||||
|
# Create new message
|
||||||
|
message = Message(
|
||||||
|
content=message_content,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
user_id=current_user.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create conversation-specific directory
|
||||||
|
conversation_dir = os.path.join(UPLOAD_FOLDER, str(conversation_id))
|
||||||
|
os.makedirs(conversation_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Handle file attachments
|
||||||
|
attachments = []
|
||||||
|
for i in range(file_count):
|
||||||
|
file = request.files.get(f'file_{i}')
|
||||||
|
if file and file.filename:
|
||||||
|
if not allowed_file(file.filename):
|
||||||
|
return jsonify({'success': False, 'error': f'File type not allowed: {file.filename}'}), 400
|
||||||
|
|
||||||
|
if file.content_length and file.content_length > MAX_FILE_SIZE:
|
||||||
|
return jsonify({'success': False, 'error': f'File size exceeds limit: {file.filename}'}), 400
|
||||||
|
|
||||||
|
# Generate unique filename
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
unique_filename = f"{timestamp}_{filename}"
|
||||||
|
file_path = os.path.join(conversation_dir, unique_filename)
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
file.save(file_path)
|
||||||
|
|
||||||
|
# Create attachment record
|
||||||
|
attachment = MessageAttachment(
|
||||||
|
name=filename,
|
||||||
|
path=file_path,
|
||||||
|
type=get_file_extension(filename),
|
||||||
|
size=os.path.getsize(file_path)
|
||||||
|
)
|
||||||
|
message.attachments.append(attachment)
|
||||||
|
attachments.append(attachment)
|
||||||
|
|
||||||
|
db.session.add(message)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
# Prepare message data for WebSocket
|
||||||
|
message_data = {
|
||||||
|
'id': message.id,
|
||||||
|
'content': message.content,
|
||||||
|
'created_at': message.created_at.strftime('%b %d, %Y %H:%M'),
|
||||||
|
'sender_id': str(current_user.id),
|
||||||
|
'sender_name': f"{current_user.username} {current_user.last_name}",
|
||||||
|
'sender_avatar': url_for('profile_pic', filename=current_user.profile_picture) if current_user.profile_picture else url_for('static', filename='default-avatar.png'),
|
||||||
|
'attachments': [{
|
||||||
|
'name': attachment.name,
|
||||||
|
'size': attachment.size,
|
||||||
|
'url': url_for('conversations.download_attachment', message_id=message.id, attachment_index=index)
|
||||||
|
} for index, attachment in enumerate(attachments)]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Emit the message to all users in the conversation room
|
||||||
|
socketio.emit('new_message', message_data, room=f'conversation_{conversation_id}')
|
||||||
|
|
||||||
|
# Return minimal response since the message will be received through WebSocket
|
||||||
|
return jsonify({'success': True})
|
||||||
|
|
||||||
|
@conversations_bp.route('/messages/<int:message_id>/attachment/<int:attachment_index>')
|
||||||
|
@login_required
|
||||||
|
def download_attachment(message_id, attachment_index):
|
||||||
|
message = Message.query.get_or_404(message_id)
|
||||||
|
conversation = message.conversation
|
||||||
|
|
||||||
|
# Check if user is a member
|
||||||
|
if not current_user.is_admin and current_user not in conversation.members:
|
||||||
|
flash('You do not have access to this file.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversation', conversation_id=conversation.id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
attachment = message.attachments[attachment_index]
|
||||||
|
return send_file(
|
||||||
|
attachment.path,
|
||||||
|
as_attachment=True,
|
||||||
|
download_name=attachment.name
|
||||||
|
)
|
||||||
|
except (IndexError, Exception) as e:
|
||||||
|
flash('File not found.', 'error')
|
||||||
|
return redirect(url_for('conversations.conversation', conversation_id=conversation.id))
|
||||||
|
|
||||||
|
@conversations_bp.route('/<int:conversation_id>/messages')
|
||||||
|
@login_required
|
||||||
|
def get_messages(conversation_id):
|
||||||
|
conversation = Conversation.query.get_or_404(conversation_id)
|
||||||
|
|
||||||
|
# Check if user is a member
|
||||||
|
if not current_user.is_admin and current_user not in conversation.members:
|
||||||
|
return jsonify({'success': False, 'error': 'You do not have access to this conversation.'}), 403
|
||||||
|
|
||||||
|
# Get the last message ID from the request
|
||||||
|
last_message_id = request.args.get('last_message_id', type=int)
|
||||||
|
|
||||||
|
# Query for new messages
|
||||||
|
query = Message.query.filter_by(conversation_id=conversation_id)
|
||||||
|
if last_message_id:
|
||||||
|
query = query.filter(Message.id > last_message_id)
|
||||||
|
|
||||||
|
messages = query.order_by(Message.created_at.asc()).all()
|
||||||
|
|
||||||
|
# Format messages for response
|
||||||
|
formatted_messages = []
|
||||||
|
for message in messages:
|
||||||
|
formatted_messages.append({
|
||||||
|
'id': message.id,
|
||||||
|
'content': message.content,
|
||||||
|
'created_at': message.created_at.strftime('%b %d, %Y %H:%M'),
|
||||||
|
'sender_id': str(message.user.id),
|
||||||
|
'sender_name': f"{message.user.username} {message.user.last_name}",
|
||||||
|
'sender_avatar': url_for('profile_pic', filename=message.user.profile_picture) if message.user.profile_picture else url_for('static', filename='default-avatar.png'),
|
||||||
|
'attachments': [{
|
||||||
|
'name': attachment.name,
|
||||||
|
'size': attachment.size,
|
||||||
|
'url': url_for('conversations.download_attachment', message_id=message.id, attachment_index=index)
|
||||||
|
} for index, attachment in enumerate(message.attachments)]
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({'success': True, 'messages': formatted_messages})
|
||||||
@@ -324,6 +324,11 @@ def init_routes(main_bp):
|
|||||||
def starred():
|
def starred():
|
||||||
return render_template('starred/starred.html')
|
return render_template('starred/starred.html')
|
||||||
|
|
||||||
|
@main_bp.route('/conversations')
|
||||||
|
@login_required
|
||||||
|
def conversations():
|
||||||
|
return redirect(url_for('conversations.conversations'))
|
||||||
|
|
||||||
@main_bp.route('/trash')
|
@main_bp.route('/trash')
|
||||||
@login_required
|
@login_required
|
||||||
def trash():
|
def trash():
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
style="height: 40px; margin-right: 10px;">
|
style="height: 40px; margin-right: 10px;">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if site_settings.company_name %}
|
{% if site_settings.company_name %}
|
||||||
DocuPulse for {% if site_settings.company_website %}<a href="{{ site_settings.company_website }}" target="_blank" style="text-decoration: none; color: #fff !important; font-weight: inherit; font-size: 1.25rem; font-family: inherit; display: inline; padding: 0; margin: 0;">{{ site_settings.company_name }}</a>{% else %}{{ site_settings.company_name }}{% endif %}
|
DocuPulse for:{% if site_settings.company_website %}<a href="{{ site_settings.company_website }}" target="_blank" style="text-decoration: none; color: #fff !important; font-weight: inherit; font-size: 1.25rem; font-family: inherit; display: inline; padding: 0; margin: 0;">{{ site_settings.company_name }}</a>{% else %}{{ site_settings.company_name }}{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
DocuPulse
|
DocuPulse
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -85,6 +85,11 @@
|
|||||||
<i class="fas fa-door-open"></i> Rooms
|
<i class="fas fa-door-open"></i> Rooms
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {% if request.endpoint == 'conversations.conversations' %}active{% endif %}" href="{{ url_for('conversations.conversations') }}">
|
||||||
|
<i class="fas fa-comments"></i> Conversations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
{% if current_user.is_admin %}
|
{% if current_user.is_admin %}
|
||||||
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">
|
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">
|
||||||
|
|||||||
685
templates/conversations/conversation.html
Normal file
685
templates/conversations/conversation.html
Normal file
@@ -0,0 +1,685 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% from "components/header.html" import header %}
|
||||||
|
|
||||||
|
{% block title %}{{ conversation.name }} - {% if site_settings.company_name %}DocuPulse for {{ site_settings.company_name }}{% else %}DocuPulse{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
.member-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
.badge-creator {
|
||||||
|
background-color: #d1f2eb;
|
||||||
|
color: #117a65;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-remove-member {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #c82333;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-remove-member:hover {
|
||||||
|
background: #f5c6cb;
|
||||||
|
color: #a71d2a;
|
||||||
|
}
|
||||||
|
/* Chat bubble styles */
|
||||||
|
.chat-messages {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.message-content {
|
||||||
|
position: relative;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
max-width: 80%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
.message.sent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.message.received {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
.message.sent .message-content {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border-top-right-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.message.received .message-content {
|
||||||
|
background-color: #f0f2f5;
|
||||||
|
color: #1c1e21;
|
||||||
|
border-top-left-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
.message-info {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
.message.sent .message-info {
|
||||||
|
text-align: right;
|
||||||
|
background-color: rgba(0, 0, 0, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
.message.received .message-info {
|
||||||
|
background-color: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #65676b;
|
||||||
|
}
|
||||||
|
.message.sent .message-info .text-muted {
|
||||||
|
color: rgba(255, 255, 255, 0.8) !important;
|
||||||
|
}
|
||||||
|
.message.received .message-info .text-muted {
|
||||||
|
color: #65676b !important;
|
||||||
|
}
|
||||||
|
.attachment {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.message.sent .attachment .btn {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.message.sent .attachment .btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
.message.received .attachment .btn {
|
||||||
|
background-color: #e4e6eb;
|
||||||
|
color: #1c1e21;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.message.received .attachment .btn:hover {
|
||||||
|
background-color: #d8dadf;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ header(
|
||||||
|
title=conversation.name,
|
||||||
|
description=conversation.description or "No description",
|
||||||
|
button_text="Back to Conversations",
|
||||||
|
button_url=url_for('conversations.conversations'),
|
||||||
|
icon="fa-comments",
|
||||||
|
button_class="btn-secondary",
|
||||||
|
button_icon="fa-arrow-left"
|
||||||
|
) }}
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Main Chat Area -->
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="chat-messages" style="min-height: 400px; max-height: 600px; overflow-y: auto;" id="chatMessages">
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="message {% if message.user.id == current_user.id %}sent{% else %}received{% endif %}" data-message-id="{{ message.id }}">
|
||||||
|
{% if message.user.id != current_user.id %}
|
||||||
|
<img src="{{ url_for('profile_pic', filename=message.user.profile_picture) if message.user.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||||
|
alt="{{ message.user.username }}"
|
||||||
|
class="rounded-circle me-2"
|
||||||
|
style="width: 40px; height: 40px; object-fit: cover;">
|
||||||
|
{% endif %}
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-info">
|
||||||
|
<span class="fw-medium">{{ message.user.username }} {{ message.user.last_name }}</span>
|
||||||
|
<span class="text-muted ms-2">{{ message.created_at.strftime('%b %d, %Y %H:%M') }}</span>
|
||||||
|
</div>
|
||||||
|
{{ message.content }}
|
||||||
|
{% if message.attachments %}
|
||||||
|
<div class="attachments mt-2">
|
||||||
|
{% for attachment in message.attachments %}
|
||||||
|
<div class="attachment mb-1">
|
||||||
|
<a href="{{ url_for('conversations.download_attachment', message_id=message.id, attachment_index=loop.index0) }}" class="btn btn-sm">
|
||||||
|
<i class="fas fa-paperclip me-1"></i>
|
||||||
|
{{ attachment.name }}
|
||||||
|
<small class="ms-1">({{ (attachment.size / 1024)|round|int }} KB)</small>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if message.user.id == current_user.id %}
|
||||||
|
<img src="{{ url_for('profile_pic', filename=message.user.profile_picture) if message.user.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||||
|
alt="{{ message.user.username }}"
|
||||||
|
class="rounded-circle ms-2"
|
||||||
|
style="width: 40px; height: 40px; object-fit: cover;">
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center text-muted py-5">
|
||||||
|
<i class="fas fa-comments fa-3x mb-3"></i>
|
||||||
|
<p>No messages yet. Start the conversation!</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-white border-top">
|
||||||
|
<form id="messageForm" class="d-flex flex-column gap-2">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<input type="text" class="form-control" id="messageInput" placeholder="Type your message..." required style="min-width:0; height: 38px;">
|
||||||
|
<label for="fileInput" class="btn btn-outline-secondary btn-sm mb-0 d-flex align-items-center justify-content-center" style="height: 38px; width: 38px;">
|
||||||
|
<i class="fas fa-paperclip"></i>
|
||||||
|
</label>
|
||||||
|
<input type="file" id="fileInput" class="d-none" multiple accept=".pdf,.docx,.doc,.txt,.rtf,.odt,.md,.csv,.xlsx,.xls,.ods,.xlsm,.pptx,.ppt,.odp,.jpg,.jpeg,.png,.gif,.bmp,.svg,.webp,.tiff,.zip,.rar,.7z,.tar,.gz,.py,.js,.html,.css,.json,.xml,.sql,.sh,.bat,.mp3,.wav,.ogg,.m4a,.flac,.mp4,.avi,.mov,.wmv,.flv,.mkv,.webm,.dwg,.dxf,.ai,.psd,.eps,.indd,.eml,.msg,.vcf,.ics">
|
||||||
|
<button type="submit" class="btn btn-primary d-flex align-items-center justify-content-center" style="height: 38px; width: 38px;">
|
||||||
|
<i class="fas fa-paper-plane"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center" style="min-height: 1.2em;">
|
||||||
|
<small id="selectedFiles" class="text-muted"></small>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Members Sidebar -->
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">
|
||||||
|
<i class="fas fa-users me-2"></i>Members
|
||||||
|
</h5>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<button type="button" class="btn btn-primary btn-sm" data-bs-toggle="modal" data-bs-target="#manageMembersModal">
|
||||||
|
<i class="fas fa-user-edit me-1"></i>Manage Members
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
{% for member in conversation.members %}
|
||||||
|
<div class="list-group-item d-flex align-items-center">
|
||||||
|
<img src="{{ url_for('profile_pic', filename=member.profile_picture) if member.profile_picture else url_for('static', filename='default-avatar.png') }}"
|
||||||
|
alt="{{ member.username }}"
|
||||||
|
class="rounded-circle me-2"
|
||||||
|
style="width: 40px; height: 40px; object-fit: cover;">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-medium">{{ member.username }} {{ member.last_name }}</div>
|
||||||
|
<div class="text-muted small">{{ member.email }}</div>
|
||||||
|
</div>
|
||||||
|
{% if member.id == conversation.created_by %}
|
||||||
|
<span class="badge bg-primary">
|
||||||
|
<i class="fas fa-crown me-1"></i>Creator
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<!-- Manage Members Modal -->
|
||||||
|
<div class="modal fade" id="manageMembersModal" tabindex="-1" aria-labelledby="manageMembersModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="manageMembersModalLabel">Manage Members</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form method="POST" action="{{ url_for('conversations.edit_conversation', conversation_id=conversation.id, redirect=url_for('conversations.conversation', conversation_id=conversation.id)) }}" id="membersForm">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="user_id" class="form-label">Select User</label>
|
||||||
|
<select class="form-select select2" id="user_id" name="user_id">
|
||||||
|
<option value="">Search for a user...</option>
|
||||||
|
{% for user in all_users %}
|
||||||
|
{% if user.id != current_user.id %}
|
||||||
|
<option value="{{ user.id }}" data-email="{{ user.email }}" data-avatar="{{ url_for('profile_pic', filename=user.profile_picture) if user.profile_picture else url_for('static', filename='default-avatar.png') }}">{{ user.username }} {{ user.last_name }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary w-100 mb-3" id="addMemberBtn">
|
||||||
|
<i class="fas fa-user-plus me-2"></i>Add Member
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="list-group" id="selectedMembersList">
|
||||||
|
<!-- Render all current members -->
|
||||||
|
{% for member in conversation.members %}
|
||||||
|
<div class="list-group-item d-flex align-items-center member-row" data-user-id="{{ member.id }}">
|
||||||
|
<img class="member-avatar" src="{{ url_for('profile_pic', filename=member.profile_picture) if member.profile_picture else url_for('static', filename='default-avatar.png') }}">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-bold">{{ member.username }} {{ member.last_name }}</div>
|
||||||
|
<div class="text-muted small">{{ member.email }}</div>
|
||||||
|
</div>
|
||||||
|
{% if member.id == conversation.created_by %}
|
||||||
|
<span class="badge badge-creator ms-2"><i class="fas fa-user"></i> Creator</span>
|
||||||
|
{% else %}
|
||||||
|
<button type="button" class="btn btn-remove-member ms-2">
|
||||||
|
<i class="fas fa-user-minus me-1"></i>Remove
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" form="membersForm" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.js"></script>
|
||||||
|
<script>
|
||||||
|
// Initialize Select2 for user selection
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('.select2').select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
width: '100%'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle member removal
|
||||||
|
$(document).on('click', '.btn-remove-member', function() {
|
||||||
|
const memberRow = $(this).closest('.member-row');
|
||||||
|
memberRow.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle adding new member
|
||||||
|
$('#addMemberBtn').on('click', function() {
|
||||||
|
const select = $('#user_id');
|
||||||
|
const selectedOption = select.find('option:selected');
|
||||||
|
const selectedValue = select.val();
|
||||||
|
|
||||||
|
if (!selectedValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = selectedValue;
|
||||||
|
const userName = selectedOption.text();
|
||||||
|
const userEmail = selectedOption.data('email');
|
||||||
|
const userAvatar = selectedOption.data('avatar');
|
||||||
|
|
||||||
|
// Check if user is already in the list
|
||||||
|
if ($(`.member-row[data-user-id="${userId}"]`).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new member row
|
||||||
|
const memberRow = `
|
||||||
|
<div class="list-group-item d-flex align-items-center member-row" data-user-id="${userId}">
|
||||||
|
<img class="member-avatar" src="${userAvatar}">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-bold">${userName}</div>
|
||||||
|
<div class="text-muted small">${userEmail}</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-remove-member ms-2">
|
||||||
|
<i class="fas fa-user-minus me-1"></i>Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add to the list
|
||||||
|
$('#selectedMembersList').append(memberRow);
|
||||||
|
|
||||||
|
// Clear the select and trigger change event
|
||||||
|
select.val(null).trigger('change');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
$('#membersForm').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Collect all member IDs
|
||||||
|
const memberIds = [];
|
||||||
|
$('.member-row').each(function() {
|
||||||
|
memberIds.push($(this).data('user-id'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove any existing hidden inputs
|
||||||
|
$('input[name="members"]').remove();
|
||||||
|
|
||||||
|
// Add hidden input for each member ID
|
||||||
|
memberIds.forEach(function(id) {
|
||||||
|
$('#membersForm').append(`<input type="hidden" name="members" value="${id}">`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Submit the form
|
||||||
|
this.submit();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Global state and socket management
|
||||||
|
if (typeof window.ChatManager === 'undefined') {
|
||||||
|
window.ChatManager = (function() {
|
||||||
|
let instance = null;
|
||||||
|
let socket = null;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
addedMessageIds: new Set(),
|
||||||
|
messageQueue: new Set(),
|
||||||
|
connectionState: {
|
||||||
|
hasJoined: false,
|
||||||
|
isConnected: false,
|
||||||
|
lastMessageId: null,
|
||||||
|
connectionAttempts: 0,
|
||||||
|
socketId: null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
if (instance) {
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize message IDs from existing messages
|
||||||
|
$('.message').each(function() {
|
||||||
|
const messageId = $(this).data('message-id');
|
||||||
|
if (messageId) {
|
||||||
|
state.addedMessageIds.add(messageId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create socket instance
|
||||||
|
socket = io({
|
||||||
|
transports: ['websocket'],
|
||||||
|
upgrade: false,
|
||||||
|
reconnection: false,
|
||||||
|
debug: true,
|
||||||
|
forceNew: false,
|
||||||
|
multiplex: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set up socket event handlers
|
||||||
|
socket.on('connect', function() {
|
||||||
|
state.connectionState.isConnected = true;
|
||||||
|
state.connectionState.connectionAttempts++;
|
||||||
|
state.connectionState.socketId = socket.id;
|
||||||
|
console.log('Socket connected:', {
|
||||||
|
attempt: state.connectionState.connectionAttempts,
|
||||||
|
socketId: socket.id,
|
||||||
|
existingSocketId: state.connectionState.socketId
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', function(reason) {
|
||||||
|
console.log('Disconnected from conversation:', {
|
||||||
|
reason: reason,
|
||||||
|
socketId: socket.id,
|
||||||
|
connectionState: state.connectionState
|
||||||
|
});
|
||||||
|
state.connectionState.isConnected = false;
|
||||||
|
state.connectionState.hasJoined = false;
|
||||||
|
state.connectionState.socketId = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('error', function(error) {
|
||||||
|
console.error('Socket error:', error);
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
instance = {
|
||||||
|
socket: socket,
|
||||||
|
state: state,
|
||||||
|
cleanup: cleanup
|
||||||
|
};
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
console.log('Cleaning up socket connection');
|
||||||
|
if (socket) {
|
||||||
|
socket.off('new_message');
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
}
|
||||||
|
instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getInstance: function() {
|
||||||
|
if (!instance) {
|
||||||
|
instance = init();
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize chat when document is ready
|
||||||
|
$(document).ready(function() {
|
||||||
|
const chat = ChatManager.getInstance();
|
||||||
|
const socket = chat.socket;
|
||||||
|
const state = chat.state;
|
||||||
|
const conversationId = "{{ conversation.id }}";
|
||||||
|
|
||||||
|
console.log('Initializing chat for conversation:', conversationId);
|
||||||
|
|
||||||
|
// Join conversation room
|
||||||
|
socket.on('connect', function() {
|
||||||
|
if (!state.connectionState.hasJoined) {
|
||||||
|
console.log('Joining conversation room:', conversationId);
|
||||||
|
socket.emit('join_conversation', {
|
||||||
|
conversation_id: conversationId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
socketId: socket.id
|
||||||
|
});
|
||||||
|
state.connectionState.hasJoined = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to append a new message to the chat
|
||||||
|
function appendMessage(message) {
|
||||||
|
const currentUserId = "{{ current_user.id }}";
|
||||||
|
const isCurrentUser = message.sender_id === currentUserId;
|
||||||
|
const messageHtml = `
|
||||||
|
<div class="message ${isCurrentUser ? 'sent' : 'received'}" data-message-id="${message.id}">
|
||||||
|
${!isCurrentUser ? `
|
||||||
|
<img src="${message.sender_avatar}"
|
||||||
|
alt="${message.sender_name}"
|
||||||
|
class="rounded-circle me-2"
|
||||||
|
style="width: 40px; height: 40px; object-fit: cover;">
|
||||||
|
` : ''}
|
||||||
|
<div class="message-content">
|
||||||
|
<div class="message-info">
|
||||||
|
<span class="fw-medium">${message.sender_name}</span>
|
||||||
|
<span class="text-muted ms-2">${message.created_at}</span>
|
||||||
|
</div>
|
||||||
|
${message.content}
|
||||||
|
${message.attachments && message.attachments.length > 0 ? `
|
||||||
|
<div class="attachments mt-2">
|
||||||
|
${message.attachments.map((attachment, index) => `
|
||||||
|
<div class="attachment mb-1">
|
||||||
|
<a href="${attachment.url}" class="btn btn-sm">
|
||||||
|
<i class="fas fa-paperclip me-1"></i>
|
||||||
|
${attachment.name}
|
||||||
|
<small class="ms-1">(${Math.round(attachment.size / 1024)} KB)</small>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
${isCurrentUser ? `
|
||||||
|
<img src="${message.sender_avatar}"
|
||||||
|
alt="${message.sender_name}"
|
||||||
|
class="rounded-circle ms-2"
|
||||||
|
style="width: 40px; height: 40px; object-fit: cover;">
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Remove the "no messages" placeholder if it exists
|
||||||
|
$('.text-center.text-muted').remove();
|
||||||
|
|
||||||
|
$('#chatMessages').append(messageHtml);
|
||||||
|
scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to bottom of chat messages
|
||||||
|
function scrollToBottom() {
|
||||||
|
const chatMessages = document.getElementById('chatMessages');
|
||||||
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
||||||
|
}
|
||||||
|
scrollToBottom();
|
||||||
|
|
||||||
|
// Message handling with deduplication
|
||||||
|
socket.on('new_message', function(message) {
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
const messageKey = `${message.id}-${socket.id}`;
|
||||||
|
|
||||||
|
console.log('Message received:', {
|
||||||
|
id: message.id,
|
||||||
|
timestamp: timestamp,
|
||||||
|
socketId: socket.id,
|
||||||
|
messageKey: messageKey,
|
||||||
|
queueSize: state.messageQueue.size
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.messageQueue.has(messageKey)) {
|
||||||
|
console.log('Message already in queue:', messageKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.messageQueue.add(messageKey);
|
||||||
|
|
||||||
|
if (!state.addedMessageIds.has(message.id)) {
|
||||||
|
console.log('Processing new message:', message.id);
|
||||||
|
appendMessage(message);
|
||||||
|
state.connectionState.lastMessageId = message.id;
|
||||||
|
state.addedMessageIds.add(message.id);
|
||||||
|
} else {
|
||||||
|
console.log('Duplicate message detected:', {
|
||||||
|
messageId: message.id,
|
||||||
|
lastMessageId: state.connectionState.lastMessageId,
|
||||||
|
socketId: socket.id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up message from queue after processing
|
||||||
|
state.messageQueue.delete(messageKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle file selection
|
||||||
|
$('#fileInput').on('change', function() {
|
||||||
|
const files = Array.from(this.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
const fileNames = files.map(file => file.name).join(', ');
|
||||||
|
$('#selectedFiles').text(files.length > 1 ? `${files.length} files selected: ${fileNames}` : fileNames);
|
||||||
|
} else {
|
||||||
|
$('#selectedFiles').text('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle message form submission
|
||||||
|
let isSubmitting = false;
|
||||||
|
$('#messageForm').off('submit').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (isSubmitting) {
|
||||||
|
console.log('Message submission already in progress');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.connectionState.isConnected) {
|
||||||
|
console.error('Socket not connected, cannot send message');
|
||||||
|
alert('Connection lost. Please refresh the page.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageInput = $('#messageInput');
|
||||||
|
const submitButton = $('#messageForm button[type="submit"]');
|
||||||
|
const message = messageInput.val().trim();
|
||||||
|
const fileInput = $('#fileInput')[0];
|
||||||
|
const files = Array.from(fileInput.files);
|
||||||
|
|
||||||
|
if (!message && files.length === 0) {
|
||||||
|
console.log('Empty message submission attempted');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Submitting message:', {
|
||||||
|
hasText: !!message,
|
||||||
|
fileCount: files.length,
|
||||||
|
socketId: socket.id,
|
||||||
|
connectionState: state.connectionState
|
||||||
|
});
|
||||||
|
|
||||||
|
isSubmitting = true;
|
||||||
|
messageInput.prop('disabled', true);
|
||||||
|
submitButton.prop('disabled', true);
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('message', message);
|
||||||
|
formData.append('csrf_token', $('input[name="csrf_token"]').val());
|
||||||
|
formData.append('socket_id', socket.id);
|
||||||
|
|
||||||
|
// Append each file to the FormData
|
||||||
|
files.forEach((file, index) => {
|
||||||
|
formData.append(`file_${index}`, file);
|
||||||
|
});
|
||||||
|
formData.append('file_count', files.length);
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: "{{ url_for('conversations.send_message', conversation_id=conversation.id) }}",
|
||||||
|
method: 'POST',
|
||||||
|
data: formData,
|
||||||
|
processData: false,
|
||||||
|
contentType: false,
|
||||||
|
success: function(response) {
|
||||||
|
console.log('Message sent successfully:', {
|
||||||
|
response: response,
|
||||||
|
socketId: socket.id
|
||||||
|
});
|
||||||
|
if (response.success) {
|
||||||
|
messageInput.val('');
|
||||||
|
fileInput.value = '';
|
||||||
|
$('#selectedFiles').text('');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(xhr, status, error) {
|
||||||
|
console.error('Failed to send message:', {
|
||||||
|
status: status,
|
||||||
|
error: error,
|
||||||
|
response: xhr.responseText
|
||||||
|
});
|
||||||
|
alert('Failed to send message. Please try again.');
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
messageInput.prop('disabled', false);
|
||||||
|
submitButton.prop('disabled', false);
|
||||||
|
isSubmitting = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up on page unload
|
||||||
|
$(window).on('beforeunload', function() {
|
||||||
|
chat.cleanup();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock content %}
|
||||||
154
templates/conversations/conversations.html
Normal file
154
templates/conversations/conversations.html
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% from "components/header.html" import header %}
|
||||||
|
|
||||||
|
{% block title %}Conversations - {% if site_settings.company_name %}DocuPulse for {{ site_settings.company_name }}{% else %}DocuPulse{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ header(
|
||||||
|
title="Conversations",
|
||||||
|
description="Manage your conversations and messages",
|
||||||
|
button_text="New Conversation" if current_user.is_admin else "",
|
||||||
|
button_url=url_for('conversations.create_conversation') if current_user.is_admin else "",
|
||||||
|
icon="fa-comments",
|
||||||
|
button_icon="fa-plus"
|
||||||
|
) }}
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<!-- Search and Filter Section -->
|
||||||
|
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
|
||||||
|
<form method="GET" class="d-flex align-items-center w-100 justify-content-between" id="conversationFilterForm" style="gap: 1rem;">
|
||||||
|
<input type="text" name="search" placeholder="Search conversations..." value="{{ search }}" class="form-control flex-grow-1" id="conversationSearchInput" autocomplete="off" style="min-width: 0;" />
|
||||||
|
<button type="button" id="clearConversationsFilter" class="px-4 py-2 rounded-lg text-white font-medium transition-colors duration-200 ms-2 flex-shrink-0"
|
||||||
|
style="background-color: var(--primary-color); border: 1px solid var(--primary-color);"
|
||||||
|
onmouseover="this.style.backgroundColor='var(--primary-light)'"
|
||||||
|
onmouseout="this.style.backgroundColor='var(--primary-color)'">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
|
||||||
|
{% for conversation in conversations %}
|
||||||
|
<div class="col">
|
||||||
|
<div class="card h-100 shadow-sm hover-shadow">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||||
|
<div>
|
||||||
|
<h5 class="card-title mb-0">{{ conversation.name }}</h5>
|
||||||
|
<div class="text-muted small mt-1">Created on {{ conversation.created_at.strftime('%b %d, %Y') }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-light text-dark">
|
||||||
|
<i class="fas fa-users"></i> {{ conversation.members|length }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="card-text text-muted">{{ conversation.description or 'No description' }}</p>
|
||||||
|
<div class="d-flex align-items-center mt-3">
|
||||||
|
<img src="{{ url_for('profile_pic', filename=conversation.creator.profile_picture) if conversation.creator and conversation.creator.profile_picture else url_for('static', filename='img/default-avatar.png') }}"
|
||||||
|
alt="Creator"
|
||||||
|
class="rounded-circle me-2"
|
||||||
|
style="width: 32px; height: 32px; object-fit: cover;">
|
||||||
|
<div>
|
||||||
|
<small class="text-muted">Created by</small>
|
||||||
|
<div class="fw-medium">{{ conversation.creator.username }} {{ conversation.creator.last_name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer bg-transparent border-top-0">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<a href="{{ url_for('conversations.conversation', conversation_id=conversation.id) }}" class="btn btn-primary flex-grow-1">
|
||||||
|
<i class="fas fa-comments me-2"></i>Open Conversation
|
||||||
|
</a>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<div class="dropdown">
|
||||||
|
<button class="btn btn-secondary dropdown-toggle" type="button" id="conversationActions{{ conversation.id }}" data-bs-toggle="dropdown" aria-expanded="false">
|
||||||
|
<i class="fas fa-ellipsis-v"></i>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="conversationActions{{ conversation.id }}">
|
||||||
|
<li>
|
||||||
|
<a class="dropdown-item" href="{{ url_for('conversations.edit_conversation', conversation_id=conversation.id) }}">
|
||||||
|
<i class="fas fa-edit me-2"></i>Edit Conversation
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li>
|
||||||
|
<button type="button" class="dropdown-item text-danger" data-bs-toggle="modal" data-bs-target="#deleteConversationModal{{ conversation.id }}">
|
||||||
|
<i class="fas fa-trash me-2"></i>Delete Conversation
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if current_user.is_admin %}
|
||||||
|
<!-- Delete Conversation Modal -->
|
||||||
|
<div class="modal fade" id="deleteConversationModal{{ conversation.id }}" tabindex="-1" aria-labelledby="deleteConversationModalLabel{{ conversation.id }}" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="deleteConversationModalLabel{{ conversation.id }}">Move to Trash</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="d-flex align-items-center gap-3 mb-3">
|
||||||
|
<i class="fas fa-trash text-danger" style="font-size: 2rem;"></i>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1">Move to Trash</h6>
|
||||||
|
<p class="text-muted mb-0" id="deleteConversationName{{ conversation.id }}">{{ conversation.name }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
This item will be moved to trash. You can restore it from the trash page within 30 days.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<form action="{{ url_for('conversations.delete_conversation', conversation_id=conversation.id) }}" method="POST" class="d-inline">
|
||||||
|
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||||
|
<button type="submit" class="btn btn-danger">
|
||||||
|
<i class="fas fa-trash me-2"></i>Delete Conversation
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
// Debounce function
|
||||||
|
function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function(...args) {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func.apply(this, args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.getElementById('conversationSearchInput');
|
||||||
|
const form = document.getElementById('conversationFilterForm');
|
||||||
|
if (searchInput && form) {
|
||||||
|
searchInput.addEventListener('input', debounce(function() {
|
||||||
|
form.submit();
|
||||||
|
}, 300));
|
||||||
|
}
|
||||||
|
// Clear button logic
|
||||||
|
const clearBtn = document.getElementById('clearConversationsFilter');
|
||||||
|
if (clearBtn && searchInput) {
|
||||||
|
clearBtn.addEventListener('click', function() {
|
||||||
|
searchInput.value = '';
|
||||||
|
form.submit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
{% endblock %}
|
||||||
238
templates/conversations/create_conversation.html
Normal file
238
templates/conversations/create_conversation.html
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
{% extends "common/base.html" %}
|
||||||
|
{% from "components/header.html" import header %}
|
||||||
|
|
||||||
|
{% block title %}Create Conversation - {% if site_settings.company_name %}DocuPulse for {{ site_settings.company_name }}{% else %}DocuPulse{% endif %}{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_css %}
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
.member-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
.badge-creator {
|
||||||
|
background-color: #d1f2eb;
|
||||||
|
color: #117a65;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-remove-member {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #c82333;
|
||||||
|
border: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn-remove-member:hover {
|
||||||
|
background: #f5c6cb;
|
||||||
|
color: #a71d2a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ header(
|
||||||
|
title=("Edit Conversation" if edit_mode else "Create New Conversation"),
|
||||||
|
description=("Update conversation details" if edit_mode else "Start a new conversation with your team"),
|
||||||
|
button_text="Cancel",
|
||||||
|
button_url=url_for('conversations.conversations'),
|
||||||
|
icon="fa-edit" if edit_mode else "fa-comments",
|
||||||
|
button_class="btn-secondary",
|
||||||
|
button_icon="fa-times"
|
||||||
|
) }}
|
||||||
|
|
||||||
|
<!-- Alert Modal -->
|
||||||
|
<div id="alertModal" class="modal fade" tabindex="-1" aria-labelledby="alertModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="alertModalLabel">Notification</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<i class="fas fa-exclamation-circle text-warning" style="font-size: 2rem;"></i>
|
||||||
|
<div id="alertModalMessage"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">OK</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<form method="POST" id="conversationForm">
|
||||||
|
{{ form.csrf_token }}
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form.name.label(class="form-label") }}
|
||||||
|
{{ form.name(class="form-control", placeholder="Enter conversation name") }}
|
||||||
|
{% if form.name.errors %}
|
||||||
|
<div class="text-danger">
|
||||||
|
{% for error in form.name.errors %}
|
||||||
|
<small>{{ error }}</small>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="{{ form.description.id }}" class="form-label">Description (Optional)</label>
|
||||||
|
{{ form.description(class="form-control", rows="3", placeholder="Enter a description (optional)") }}
|
||||||
|
{% if form.description.errors %}
|
||||||
|
<div class="text-danger">
|
||||||
|
{% for error in form.description.errors %}
|
||||||
|
<small>{{ error }}</small>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
{{ "Edit Conversation" if edit_mode else "Create Conversation" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white">
|
||||||
|
<h5 class="card-title mb-0">Add Members</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="user_id" class="form-label">Select User</label>
|
||||||
|
<select class="form-select select2" id="user_id" name="user_id">
|
||||||
|
<option value="">Search for a user...</option>
|
||||||
|
{% for user in form.members.choices %}
|
||||||
|
{% if user[0] != current_user.id %}
|
||||||
|
<option value="{{ user[0] }}" data-email="{{ user[2] if user|length > 2 else '' }}" data-avatar="{{ url_for('profile_pic', filename=user[3]) if user|length > 3 and user[3] else url_for('static', filename='default-avatar.png') }}">{{ user[1] }}</option>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn btn-primary w-100 mb-3" id="addMemberBtn">
|
||||||
|
<i class="fas fa-user-plus me-2"></i>Add Member
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="list-group" id="selectedMembersList">
|
||||||
|
<!-- Render creator row server-side -->
|
||||||
|
<div class="list-group-item d-flex align-items-center member-row" data-user-id="{{ current_user.id }}">
|
||||||
|
<img class="member-avatar" src="{{ url_for('profile_pic', filename=current_user.profile_picture) if current_user.profile_picture else url_for('static', filename='default-avatar.png') }}">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="fw-bold">{{ current_user.username }} {{ current_user.last_name }}</div>
|
||||||
|
<div class="text-muted small">{{ current_user.email }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-creator ms-2"><i class="fas fa-user"></i> Creator</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
|
<script>
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('.select2').select2({
|
||||||
|
theme: 'bootstrap-5',
|
||||||
|
width: '100%',
|
||||||
|
placeholder: 'Search for a user...',
|
||||||
|
allowClear: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep track of added members
|
||||||
|
var addedMembers = new Set();
|
||||||
|
var creatorId = '{{ current_user.id }}';
|
||||||
|
addedMembers.add(creatorId);
|
||||||
|
|
||||||
|
// Function to show alert modal
|
||||||
|
function showAlert(message) {
|
||||||
|
$('#alertModalMessage').text(message);
|
||||||
|
var alertModal = new bootstrap.Modal(document.getElementById('alertModal'));
|
||||||
|
alertModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Add Member button click
|
||||||
|
$('#addMemberBtn').click(function() {
|
||||||
|
var selectedUserId = $('#user_id').val();
|
||||||
|
var selectedUserName = $('#user_id option:selected').text();
|
||||||
|
var selectedUserEmail = $('#user_id option:selected').data('email') || '';
|
||||||
|
var selectedUserAvatar = $('#user_id option:selected').data('avatar') || "{{ url_for('static', filename='default-avatar.png') }}";
|
||||||
|
|
||||||
|
if (!selectedUserId) {
|
||||||
|
showAlert('Please select a user to add.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addedMembers.has(selectedUserId)) {
|
||||||
|
showAlert('This user has already been added.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to the set of added members
|
||||||
|
addedMembers.add(selectedUserId);
|
||||||
|
|
||||||
|
// Disable the option in the dropdown
|
||||||
|
$('#user_id option[value="' + selectedUserId + '"]').prop('disabled', true);
|
||||||
|
$('#user_id').val(null).trigger('change');
|
||||||
|
|
||||||
|
// Create the member list item
|
||||||
|
var memberItem = $('<div class="list-group-item d-flex align-items-center member-row" data-user-id="' + selectedUserId + '">')
|
||||||
|
.append($('<img class="member-avatar">').attr('src', selectedUserAvatar))
|
||||||
|
.append($('<div class="flex-grow-1">')
|
||||||
|
.append($('<div class="fw-bold">').text(selectedUserName))
|
||||||
|
.append($('<div class="text-muted small">').text(selectedUserEmail))
|
||||||
|
)
|
||||||
|
.append($('<button type="button" class="btn btn-remove-member ms-2">')
|
||||||
|
.append($('<i class="fas fa-user-minus me-1"></i>'))
|
||||||
|
.append('Remove')
|
||||||
|
.click(function() {
|
||||||
|
$(this).closest('.list-group-item').remove();
|
||||||
|
addedMembers.delete(selectedUserId);
|
||||||
|
// Re-enable the option in the dropdown
|
||||||
|
$('#user_id option[value="' + selectedUserId + '"]').prop('disabled', false);
|
||||||
|
$('#user_id').trigger('change');
|
||||||
|
updateHiddenInputs();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add to the list
|
||||||
|
$('#selectedMembersList').append(memberItem);
|
||||||
|
|
||||||
|
// Update hidden inputs
|
||||||
|
updateHiddenInputs();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateHiddenInputs() {
|
||||||
|
// Remove any existing members inputs
|
||||||
|
$('#conversationForm input[name="members"]').remove();
|
||||||
|
|
||||||
|
// Add new hidden inputs for each member
|
||||||
|
addedMembers.forEach(function(memberId) {
|
||||||
|
var input = $('<input>')
|
||||||
|
.attr('type', 'hidden')
|
||||||
|
.attr('name', 'members')
|
||||||
|
.val(memberId);
|
||||||
|
$('#conversationForm').append(input);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize hidden inputs
|
||||||
|
updateHiddenInputs();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
BIN
uploads/13/20250526_145757_Kobe_Amerijckx.docx
Normal file
BIN
uploads/13/20250526_145757_Kobe_Amerijckx.docx
Normal file
Binary file not shown.
BIN
uploads/15/20250526_150145_logo-light.png
Normal file
BIN
uploads/15/20250526_150145_logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
uploads/15/20250526_150153_logo-light.png
Normal file
BIN
uploads/15/20250526_150153_logo-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
uploads/15/20250526_150153_logo-placeholder.png
Normal file
BIN
uploads/15/20250526_150153_logo-placeholder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
Reference in New Issue
Block a user