feat(messages): group messaging and search
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
Some checks are pending
NordaBiz Tests / Unit & Integration Tests (push) Waiting to run
NordaBiz Tests / E2E Tests (Playwright) (push) Blocked by required conditions
NordaBiz Tests / Smoke Tests (Production) (push) Blocked by required conditions
NordaBiz Tests / Send Failure Notification (push) Blocked by required conditions
- Search bar in inbox/sent: filters by subject, content, sender/recipient - Group chats: create named or ad-hoc groups with Norda members - Group roles: owner, moderator, member with permission hierarchy - Group management: add/remove members, change roles - Photo avatars in message list (fallback to initials) - Unread count API extended to include group messages - Migration 088: message_group, message_group_member, group_message tables Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b0907c2ad
commit
3a18ebcb28
@ -265,6 +265,15 @@ def register_blueprints(app):
|
||||
'api_notification_mark_read': 'messages.api_notification_mark_read',
|
||||
'api_notifications_mark_all_read': 'messages.api_notifications_mark_all_read',
|
||||
'api_notifications_unread_count': 'messages.api_notifications_unread_count',
|
||||
'group_compose': 'messages.group_compose',
|
||||
'group_create': 'messages.group_create',
|
||||
'group_view': 'messages.group_view',
|
||||
'group_send': 'messages.group_send',
|
||||
'group_manage': 'messages.group_manage',
|
||||
'group_add_member': 'messages.group_add_member',
|
||||
'group_remove_member': 'messages.group_remove_member',
|
||||
'group_change_role': 'messages.group_change_role',
|
||||
'group_edit': 'messages.group_edit',
|
||||
})
|
||||
logger.info("Created messages endpoint aliases")
|
||||
except ImportError as e:
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
Messages Blueprint
|
||||
==================
|
||||
|
||||
Private messages and notifications routes.
|
||||
Private messages, group messages, and notifications routes.
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
@ -10,3 +10,4 @@ from flask import Blueprint
|
||||
bp = Blueprint('messages', __name__)
|
||||
|
||||
from . import routes # noqa: E402, F401
|
||||
from . import group_routes # noqa: E402, F401
|
||||
|
||||
594
blueprints/messages/group_routes.py
Normal file
594
blueprints/messages/group_routes.py
Normal file
@ -0,0 +1,594 @@
|
||||
"""
|
||||
Group Messages Routes
|
||||
=====================
|
||||
|
||||
Group chat creation, viewing, and messaging.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from flask import render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from . import bp
|
||||
from sqlalchemy.orm import joinedload
|
||||
from sqlalchemy import func
|
||||
from database import (SessionLocal, User, Company, UserCompanyPermissions,
|
||||
MessageGroup, MessageGroupMember, GroupMessage,
|
||||
MessageAttachment, UserNotification, UserBlock)
|
||||
from extensions import limiter
|
||||
from utils.helpers import sanitize_html
|
||||
from utils.decorators import member_required
|
||||
from email_service import send_email, build_message_notification_email
|
||||
from message_upload_service import MessageUploadService
|
||||
|
||||
|
||||
def _get_active_norda_members(db, exclude_user_id):
|
||||
"""Pobierz aktywnych członków Nordy do wyboru."""
|
||||
users_with_companies = db.query(
|
||||
User,
|
||||
Company.name.label('company_name')
|
||||
).outerjoin(
|
||||
UserCompanyPermissions,
|
||||
UserCompanyPermissions.user_id == User.id
|
||||
).outerjoin(
|
||||
Company,
|
||||
(Company.id == UserCompanyPermissions.company_id) & (Company.status == 'active')
|
||||
).filter(
|
||||
User.is_active == True,
|
||||
User.is_verified == True,
|
||||
User.id != exclude_user_id
|
||||
).order_by(User.name).all()
|
||||
|
||||
seen_ids = set()
|
||||
users = []
|
||||
for user, company_name in users_with_companies:
|
||||
if user.id not in seen_ids:
|
||||
seen_ids.add(user.id)
|
||||
user._company_name = company_name
|
||||
users.append(user)
|
||||
return users
|
||||
|
||||
|
||||
def _check_group_access(db, group_id, user_id):
|
||||
"""Sprawdź czy użytkownik jest członkiem grupy. Zwraca (group, membership) lub (None, None)."""
|
||||
group = db.query(MessageGroup).filter(MessageGroup.id == group_id).first()
|
||||
if not group:
|
||||
return None, None
|
||||
membership = db.query(MessageGroupMember).filter(
|
||||
MessageGroupMember.group_id == group_id,
|
||||
MessageGroupMember.user_id == user_id
|
||||
).first()
|
||||
if not membership:
|
||||
return None, None
|
||||
return group, membership
|
||||
|
||||
|
||||
def _check_block_for_group(db, group_id, target_user_id):
|
||||
"""Sprawdź czy dodanie użytkownika do grupy jest zablokowane przez UserBlock."""
|
||||
member_ids = [m.user_id for m in db.query(MessageGroupMember.user_id).filter(
|
||||
MessageGroupMember.group_id == group_id
|
||||
).all()]
|
||||
block = db.query(UserBlock).filter(
|
||||
((UserBlock.user_id == target_user_id) & (UserBlock.blocked_user_id.in_(member_ids))) |
|
||||
((UserBlock.user_id.in_(member_ids)) & (UserBlock.blocked_user_id == target_user_id))
|
||||
).first()
|
||||
return block is not None
|
||||
|
||||
|
||||
@bp.route('/wiadomosci/nowa-grupa')
|
||||
@login_required
|
||||
@member_required
|
||||
def group_compose():
|
||||
"""Formularz tworzenia grupy"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
users = _get_active_norda_members(db, current_user.id)
|
||||
return render_template('messages/group_compose.html', users=users)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/wiadomosci/grupa/utworz', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def group_create():
|
||||
"""Utwórz grupę i wyślij pierwszą wiadomość"""
|
||||
name = request.form.get('name', '').strip()
|
||||
content = sanitize_html(request.form.get('content', '').strip())
|
||||
member_ids = request.form.getlist('members', type=int)
|
||||
|
||||
if not content:
|
||||
flash('Treść pierwszej wiadomości jest wymagana.', 'error')
|
||||
return redirect(url_for('.group_compose'))
|
||||
|
||||
if not member_ids:
|
||||
flash('Wybierz co najmniej jedną osobę.', 'error')
|
||||
return redirect(url_for('.group_compose'))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Verify all members exist and are active Norda members
|
||||
valid_members = db.query(User).filter(
|
||||
User.id.in_(member_ids),
|
||||
User.is_active == True,
|
||||
User.is_verified == True
|
||||
).all()
|
||||
|
||||
if not valid_members:
|
||||
flash('Nie znaleziono wybranych użytkowników.', 'error')
|
||||
return redirect(url_for('.group_compose'))
|
||||
|
||||
# Check blocks
|
||||
for member in valid_members:
|
||||
block = db.query(UserBlock).filter(
|
||||
((UserBlock.user_id == current_user.id) & (UserBlock.blocked_user_id == member.id)) |
|
||||
((UserBlock.user_id == member.id) & (UserBlock.blocked_user_id == current_user.id))
|
||||
).first()
|
||||
if block:
|
||||
flash(f'Nie można dodać użytkownika {member.name or member.email} do grupy.', 'error')
|
||||
return redirect(url_for('.group_compose'))
|
||||
|
||||
# Create group
|
||||
group = MessageGroup(
|
||||
name=name if name else None,
|
||||
is_named=bool(name),
|
||||
owner_id=current_user.id
|
||||
)
|
||||
db.add(group)
|
||||
db.flush()
|
||||
|
||||
# Add owner as member
|
||||
owner_member = MessageGroupMember(
|
||||
group_id=group.id,
|
||||
user_id=current_user.id,
|
||||
role='owner',
|
||||
last_read_at=datetime.now()
|
||||
)
|
||||
db.add(owner_member)
|
||||
|
||||
# Add selected members
|
||||
for member in valid_members:
|
||||
if member.id != current_user.id:
|
||||
gm = MessageGroupMember(
|
||||
group_id=group.id,
|
||||
user_id=member.id,
|
||||
role='member',
|
||||
added_by_id=current_user.id
|
||||
)
|
||||
db.add(gm)
|
||||
|
||||
# Create first message
|
||||
msg = GroupMessage(
|
||||
group_id=group.id,
|
||||
sender_id=current_user.id,
|
||||
content=content
|
||||
)
|
||||
db.add(msg)
|
||||
db.flush()
|
||||
|
||||
# Process attachments
|
||||
if request.files.getlist('attachments'):
|
||||
upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
files = [f for f in request.files.getlist('attachments') if f and f.filename]
|
||||
if files:
|
||||
valid_files, errors = upload_service.validate_files(files)
|
||||
if errors:
|
||||
db.rollback()
|
||||
for err in errors:
|
||||
flash(err, 'error')
|
||||
return redirect(url_for('.group_compose'))
|
||||
for f, filename, ext, size, file_content in valid_files:
|
||||
stored_filename, _ = upload_service.save_file(file_content, ext)
|
||||
att = MessageAttachment(
|
||||
group_message_id=msg.id,
|
||||
filename=filename,
|
||||
stored_filename=stored_filename,
|
||||
file_size=size,
|
||||
mime_type=upload_service.get_mime_type(ext)
|
||||
)
|
||||
db.add(att)
|
||||
|
||||
group.updated_at = datetime.now()
|
||||
|
||||
# Notifications for all members
|
||||
sender_name = current_user.name or current_user.email.split('@')[0]
|
||||
group_display = name if name else 'Nowa grupa'
|
||||
for member in valid_members:
|
||||
if member.id != current_user.id:
|
||||
notif = UserNotification(
|
||||
user_id=member.id,
|
||||
title=f'Dodano do grupy: {group_display}',
|
||||
message=f'{sender_name} utworzył(a) grupę i wysłał(a) pierwszą wiadomość',
|
||||
notification_type='message',
|
||||
related_type='group',
|
||||
related_id=group.id,
|
||||
action_url=url_for('.group_view', group_id=group.id)
|
||||
)
|
||||
db.add(notif)
|
||||
|
||||
# Email notification
|
||||
if member.notify_email_messages != False and member.email:
|
||||
try:
|
||||
message_url = url_for('.group_view', group_id=group.id, _external=True)
|
||||
settings_url = url_for('auth.konto_prywatnosc', _external=True)
|
||||
preview = (content[:200] + '...') if len(content) > 200 else content
|
||||
email_html, email_text = build_message_notification_email(
|
||||
sender_name=sender_name,
|
||||
subject=f'Grupa: {group_display}',
|
||||
content_preview=preview,
|
||||
message_url=message_url,
|
||||
settings_url=settings_url
|
||||
)
|
||||
send_email(
|
||||
to=[member.email],
|
||||
subject=f'Nowa grupa: {group_display} — Norda Biznes',
|
||||
body_text=email_text,
|
||||
body_html=email_html,
|
||||
email_type='message_notification',
|
||||
user_id=member.id,
|
||||
recipient_name=member.name
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to send group email to {member.email}")
|
||||
|
||||
db.commit()
|
||||
flash('Grupa utworzona!', 'success')
|
||||
return redirect(url_for('.group_view', group_id=group.id))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/wiadomosci/grupa/<int:group_id>')
|
||||
@login_required
|
||||
@member_required
|
||||
def group_view(group_id):
|
||||
"""Widok czatu grupowego"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
group, membership = _check_group_access(db, group_id, current_user.id)
|
||||
if not group:
|
||||
flash('Grupa nie istnieje lub nie masz dostępu.', 'error')
|
||||
return redirect(url_for('.messages_inbox'))
|
||||
|
||||
# Load messages with senders and attachments
|
||||
messages = db.query(GroupMessage).options(
|
||||
joinedload(GroupMessage.sender),
|
||||
joinedload(GroupMessage.attachments)
|
||||
).filter(
|
||||
GroupMessage.group_id == group_id
|
||||
).order_by(GroupMessage.created_at.asc()).all()
|
||||
|
||||
# Load members with user details
|
||||
members = db.query(MessageGroupMember).options(
|
||||
joinedload(MessageGroupMember.user)
|
||||
).filter(
|
||||
MessageGroupMember.group_id == group_id
|
||||
).order_by(MessageGroupMember.role, MessageGroupMember.joined_at).all()
|
||||
|
||||
# Mark as read
|
||||
membership.last_read_at = datetime.now()
|
||||
db.commit()
|
||||
|
||||
return render_template('messages/group_view.html',
|
||||
group=group,
|
||||
messages=messages,
|
||||
members=members,
|
||||
membership=membership
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/wiadomosci/grupa/<int:group_id>/wyslij', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def group_send(group_id):
|
||||
"""Wyślij wiadomość do grupy"""
|
||||
content = sanitize_html(request.form.get('content', '').strip())
|
||||
|
||||
if not content:
|
||||
flash('Treść jest wymagana.', 'error')
|
||||
return redirect(url_for('.group_view', group_id=group_id))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
group, membership = _check_group_access(db, group_id, current_user.id)
|
||||
if not group:
|
||||
flash('Grupa nie istnieje lub nie masz dostępu.', 'error')
|
||||
return redirect(url_for('.messages_inbox'))
|
||||
|
||||
msg = GroupMessage(
|
||||
group_id=group_id,
|
||||
sender_id=current_user.id,
|
||||
content=content
|
||||
)
|
||||
db.add(msg)
|
||||
db.flush()
|
||||
|
||||
# Process attachments
|
||||
if request.files.getlist('attachments'):
|
||||
upload_service = MessageUploadService(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
files = [f for f in request.files.getlist('attachments') if f and f.filename]
|
||||
if files:
|
||||
valid_files, errors = upload_service.validate_files(files)
|
||||
if errors:
|
||||
db.rollback()
|
||||
for err in errors:
|
||||
flash(err, 'error')
|
||||
return redirect(url_for('.group_view', group_id=group_id))
|
||||
for f, filename, ext, size, file_content in valid_files:
|
||||
stored_filename, _ = upload_service.save_file(file_content, ext)
|
||||
att = MessageAttachment(
|
||||
group_message_id=msg.id,
|
||||
filename=filename,
|
||||
stored_filename=stored_filename,
|
||||
file_size=size,
|
||||
mime_type=upload_service.get_mime_type(ext)
|
||||
)
|
||||
db.add(att)
|
||||
|
||||
group.updated_at = datetime.now()
|
||||
membership.last_read_at = datetime.now()
|
||||
|
||||
# Notify all other members
|
||||
sender_name = current_user.name or current_user.email.split('@')[0]
|
||||
group_display = group.name or group.display_name
|
||||
other_members = db.query(MessageGroupMember).options(
|
||||
joinedload(MessageGroupMember.user)
|
||||
).filter(
|
||||
MessageGroupMember.group_id == group_id,
|
||||
MessageGroupMember.user_id != current_user.id
|
||||
).all()
|
||||
|
||||
for m in other_members:
|
||||
notif = UserNotification(
|
||||
user_id=m.user_id,
|
||||
title=f'{group_display} — nowa wiadomość',
|
||||
message=f'{sender_name}: {content[:80]}...' if len(content) > 80 else f'{sender_name}: {content}',
|
||||
notification_type='message',
|
||||
related_type='group',
|
||||
related_id=group.id,
|
||||
action_url=url_for('.group_view', group_id=group_id)
|
||||
)
|
||||
db.add(notif)
|
||||
|
||||
if m.user and m.user.notify_email_messages != False and m.user.email:
|
||||
try:
|
||||
message_url = url_for('.group_view', group_id=group_id, _external=True)
|
||||
settings_url = url_for('auth.konto_prywatnosc', _external=True)
|
||||
preview = (content[:200] + '...') if len(content) > 200 else content
|
||||
email_html, email_text = build_message_notification_email(
|
||||
sender_name=sender_name,
|
||||
subject=f'Grupa: {group_display}',
|
||||
content_preview=preview,
|
||||
message_url=message_url,
|
||||
settings_url=settings_url
|
||||
)
|
||||
send_email(
|
||||
to=[m.user.email],
|
||||
subject=f'{group_display} — nowa wiadomość od {sender_name}',
|
||||
body_text=email_text,
|
||||
body_html=email_html,
|
||||
email_type='message_notification',
|
||||
user_id=m.user_id,
|
||||
recipient_name=m.user.name
|
||||
)
|
||||
except Exception:
|
||||
import logging
|
||||
logging.getLogger(__name__).warning(f"Failed to send group email to {m.user.email}")
|
||||
|
||||
db.commit()
|
||||
return redirect(url_for('.group_view', group_id=group_id))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GROUP MANAGEMENT ROUTES
|
||||
# ============================================================
|
||||
|
||||
@bp.route('/wiadomosci/grupa/<int:group_id>/zarzadzaj')
|
||||
@login_required
|
||||
@member_required
|
||||
def group_manage(group_id):
|
||||
"""Panel zarządzania członkami grupy"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
group, membership = _check_group_access(db, group_id, current_user.id)
|
||||
if not group or not membership.can_manage_members:
|
||||
flash('Brak uprawnień do zarządzania grupą.', 'error')
|
||||
return redirect(url_for('.group_view', group_id=group_id))
|
||||
|
||||
members = db.query(MessageGroupMember).options(
|
||||
joinedload(MessageGroupMember.user)
|
||||
).filter(
|
||||
MessageGroupMember.group_id == group_id
|
||||
).order_by(MessageGroupMember.role, MessageGroupMember.joined_at).all()
|
||||
|
||||
available_users = _get_active_norda_members(db, current_user.id)
|
||||
member_ids = {m.user_id for m in members}
|
||||
available_users = [u for u in available_users if u.id not in member_ids]
|
||||
|
||||
return render_template('messages/group_manage.html',
|
||||
group=group,
|
||||
members=members,
|
||||
membership=membership,
|
||||
available_users=available_users
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/wiadomosci/grupa/<int:group_id>/dodaj-czlonka', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def group_add_member(group_id):
|
||||
"""Dodaj osobę do grupy"""
|
||||
user_id = request.form.get('user_id', type=int)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
group, membership = _check_group_access(db, group_id, current_user.id)
|
||||
if not group or not membership.can_manage_members:
|
||||
flash('Brak uprawnień.', 'error')
|
||||
return redirect(url_for('.group_view', group_id=group_id))
|
||||
|
||||
if not user_id:
|
||||
flash('Nie wybrano użytkownika.', 'error')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
|
||||
# Check user exists and is active
|
||||
user = db.query(User).filter(User.id == user_id, User.is_active == True).first()
|
||||
if not user:
|
||||
flash('Użytkownik nie istnieje.', 'error')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
|
||||
# Check not already member
|
||||
existing = db.query(MessageGroupMember).filter(
|
||||
MessageGroupMember.group_id == group_id,
|
||||
MessageGroupMember.user_id == user_id
|
||||
).first()
|
||||
if existing:
|
||||
flash('Użytkownik jest już członkiem grupy.', 'error')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
|
||||
# Check blocks
|
||||
if _check_block_for_group(db, group_id, user_id):
|
||||
flash('Nie można dodać tego użytkownika do grupy.', 'error')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
|
||||
new_member = MessageGroupMember(
|
||||
group_id=group_id,
|
||||
user_id=user_id,
|
||||
role='member',
|
||||
added_by_id=current_user.id
|
||||
)
|
||||
db.add(new_member)
|
||||
|
||||
# Notification
|
||||
adder_name = current_user.name or current_user.email.split('@')[0]
|
||||
group_display = group.name or group.display_name
|
||||
notif = UserNotification(
|
||||
user_id=user_id,
|
||||
title=f'Dodano do grupy: {group_display}',
|
||||
message=f'{adder_name} dodał(a) Cię do grupy',
|
||||
notification_type='message',
|
||||
related_type='group',
|
||||
related_id=group.id,
|
||||
action_url=url_for('.group_view', group_id=group_id)
|
||||
)
|
||||
db.add(notif)
|
||||
|
||||
db.commit()
|
||||
flash(f'Dodano {user.name or user.email} do grupy.', 'success')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/wiadomosci/grupa/<int:group_id>/usun-czlonka', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def group_remove_member(group_id):
|
||||
"""Usuń osobę z grupy"""
|
||||
user_id = request.form.get('user_id', type=int)
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
group, membership = _check_group_access(db, group_id, current_user.id)
|
||||
if not group or not membership.can_manage_members:
|
||||
flash('Brak uprawnień.', 'error')
|
||||
return redirect(url_for('.group_view', group_id=group_id))
|
||||
|
||||
if user_id == group.owner_id:
|
||||
flash('Nie można usunąć właściciela grupy.', 'error')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
|
||||
target = db.query(MessageGroupMember).filter(
|
||||
MessageGroupMember.group_id == group_id,
|
||||
MessageGroupMember.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not target:
|
||||
flash('Użytkownik nie jest członkiem grupy.', 'error')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
|
||||
# Moderator cannot remove another moderator (only owner can)
|
||||
if target.role == 'moderator' and not membership.is_owner:
|
||||
flash('Tylko właściciel może usunąć moderatora.', 'error')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
db.delete(target)
|
||||
db.commit()
|
||||
flash(f'Usunięto {user.name or user.email if user else "użytkownika"} z grupy.', 'success')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/wiadomosci/grupa/<int:group_id>/zmien-role', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def group_change_role(group_id):
|
||||
"""Zmień rolę członka (tylko owner)"""
|
||||
user_id = request.form.get('user_id', type=int)
|
||||
new_role = request.form.get('role')
|
||||
|
||||
if new_role not in ('moderator', 'member'):
|
||||
flash('Nieprawidłowa rola.', 'error')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
group, membership = _check_group_access(db, group_id, current_user.id)
|
||||
if not group or not membership.is_owner:
|
||||
flash('Tylko właściciel może zmieniać role.', 'error')
|
||||
return redirect(url_for('.group_view', group_id=group_id))
|
||||
|
||||
target = db.query(MessageGroupMember).filter(
|
||||
MessageGroupMember.group_id == group_id,
|
||||
MessageGroupMember.user_id == user_id
|
||||
).first()
|
||||
|
||||
if not target or target.is_owner:
|
||||
flash('Nie można zmienić roli tego użytkownika.', 'error')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
|
||||
target.role = new_role
|
||||
db.commit()
|
||||
|
||||
role_label = 'moderatora' if new_role == 'moderator' else 'uczestnika'
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
flash(f'{user.name or user.email if user else "Użytkownik"} — rola zmieniona na {role_label}.', 'success')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@bp.route('/wiadomosci/grupa/<int:group_id>/edytuj', methods=['POST'])
|
||||
@login_required
|
||||
@member_required
|
||||
def group_edit(group_id):
|
||||
"""Edytuj nazwę i opis grupy (tylko owner)"""
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
group, membership = _check_group_access(db, group_id, current_user.id)
|
||||
if not group or not membership.is_owner:
|
||||
flash('Tylko właściciel może edytować grupę.', 'error')
|
||||
return redirect(url_for('.group_view', group_id=group_id))
|
||||
|
||||
group.name = name if name else None
|
||||
group.is_named = bool(name)
|
||||
group.description = description if description else None
|
||||
db.commit()
|
||||
flash('Grupa zaktualizowana.', 'success')
|
||||
return redirect(url_for('.group_manage', group_id=group_id))
|
||||
finally:
|
||||
db.close()
|
||||
@ -31,6 +31,7 @@ def messages_inbox():
|
||||
"""Skrzynka odbiorcza"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
search_query = request.args.get('q', '').strip()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@ -38,9 +39,24 @@ def messages_inbox():
|
||||
joinedload(PrivateMessage.attachments)
|
||||
).filter(
|
||||
PrivateMessage.recipient_id == current_user.id
|
||||
).order_by(PrivateMessage.created_at.desc())
|
||||
)
|
||||
|
||||
total = db.query(PrivateMessage).filter(PrivateMessage.recipient_id == current_user.id).count()
|
||||
# Search filtering
|
||||
if search_query:
|
||||
from sqlalchemy import or_, func
|
||||
search_pattern = f'%{search_query}%'
|
||||
query = query.join(User, PrivateMessage.sender_id == User.id).filter(
|
||||
or_(
|
||||
PrivateMessage.subject.ilike(search_pattern),
|
||||
func.regexp_replace(PrivateMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern),
|
||||
User.name.ilike(search_pattern),
|
||||
User.email.ilike(search_pattern)
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(PrivateMessage.created_at.desc())
|
||||
|
||||
total = query.count()
|
||||
messages = query.limit(per_page).offset((page - 1) * per_page).all()
|
||||
|
||||
unread_count = db.query(PrivateMessage).filter(
|
||||
@ -48,11 +64,65 @@ def messages_inbox():
|
||||
PrivateMessage.is_read == False
|
||||
).count()
|
||||
|
||||
# Fetch group chats for current user
|
||||
from database import MessageGroupMember, MessageGroup, GroupMessage
|
||||
|
||||
group_items = []
|
||||
group_memberships = db.query(MessageGroupMember).filter(
|
||||
MessageGroupMember.user_id == current_user.id
|
||||
).all()
|
||||
|
||||
for ms in group_memberships:
|
||||
group = db.query(MessageGroup).filter(MessageGroup.id == ms.group_id).first()
|
||||
if not group:
|
||||
continue
|
||||
|
||||
# Apply search filter to groups
|
||||
if search_query:
|
||||
from sqlalchemy import or_, func
|
||||
search_pattern = f'%{search_query}%'
|
||||
# Check group name or any message content
|
||||
name_match = group.name and search_query.lower() in group.name.lower()
|
||||
content_match = db.query(GroupMessage).filter(
|
||||
GroupMessage.group_id == group.id,
|
||||
func.regexp_replace(GroupMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern)
|
||||
).first()
|
||||
if not name_match and not content_match:
|
||||
continue
|
||||
|
||||
last_msg = db.query(GroupMessage).filter(
|
||||
GroupMessage.group_id == group.id
|
||||
).order_by(GroupMessage.created_at.desc()).first()
|
||||
|
||||
grp_unread = 0
|
||||
if ms.last_read_at:
|
||||
grp_unread = db.query(GroupMessage).filter(
|
||||
GroupMessage.group_id == group.id,
|
||||
GroupMessage.sender_id != current_user.id,
|
||||
GroupMessage.created_at > ms.last_read_at
|
||||
).count()
|
||||
else:
|
||||
grp_unread = db.query(GroupMessage).filter(
|
||||
GroupMessage.group_id == group.id,
|
||||
GroupMessage.sender_id != current_user.id
|
||||
).count()
|
||||
|
||||
group_items.append({
|
||||
'type': 'group',
|
||||
'group': group,
|
||||
'last_message': last_msg,
|
||||
'unread_count': grp_unread,
|
||||
'membership': ms,
|
||||
'sort_date': last_msg.created_at if last_msg else group.created_at
|
||||
})
|
||||
|
||||
return render_template('messages/inbox.html',
|
||||
messages=messages,
|
||||
page=page,
|
||||
total_pages=(total + per_page - 1) // per_page,
|
||||
unread_count=unread_count
|
||||
unread_count=unread_count,
|
||||
search_query=search_query,
|
||||
group_items=group_items
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@ -65,6 +135,7 @@ def messages_sent():
|
||||
"""Wysłane wiadomości"""
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20
|
||||
search_query = request.args.get('q', '').strip()
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
@ -72,15 +143,30 @@ def messages_sent():
|
||||
joinedload(PrivateMessage.attachments)
|
||||
).filter(
|
||||
PrivateMessage.sender_id == current_user.id
|
||||
).order_by(PrivateMessage.created_at.desc())
|
||||
)
|
||||
|
||||
total = db.query(PrivateMessage).filter(PrivateMessage.sender_id == current_user.id).count()
|
||||
if search_query:
|
||||
from sqlalchemy import or_, func
|
||||
search_pattern = f'%{search_query}%'
|
||||
query = query.join(User, PrivateMessage.recipient_id == User.id).filter(
|
||||
or_(
|
||||
PrivateMessage.subject.ilike(search_pattern),
|
||||
func.regexp_replace(PrivateMessage.content, '<[^>]+>', '', 'g').ilike(search_pattern),
|
||||
User.name.ilike(search_pattern),
|
||||
User.email.ilike(search_pattern)
|
||||
)
|
||||
)
|
||||
|
||||
query = query.order_by(PrivateMessage.created_at.desc())
|
||||
|
||||
total = query.count()
|
||||
messages = query.limit(per_page).offset((page - 1) * per_page).all()
|
||||
|
||||
return render_template('messages/sent.html',
|
||||
messages=messages,
|
||||
page=page,
|
||||
total_pages=(total + per_page - 1) // per_page
|
||||
total_pages=(total + per_page - 1) // per_page,
|
||||
search_query=search_query
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
@ -505,14 +591,33 @@ def messages_reply(message_id):
|
||||
@login_required
|
||||
@member_required
|
||||
def api_unread_count():
|
||||
"""API: Liczba nieprzeczytanych wiadomości"""
|
||||
"""API: Liczba nieprzeczytanych wiadomości (1:1 + grupowe)"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
count = db.query(PrivateMessage).filter(
|
||||
from sqlalchemy import func
|
||||
from database import MessageGroupMember, GroupMessage
|
||||
|
||||
# 1:1 unread
|
||||
pm_count = db.query(PrivateMessage).filter(
|
||||
PrivateMessage.recipient_id == current_user.id,
|
||||
PrivateMessage.is_read == False
|
||||
).count()
|
||||
return jsonify({'count': count})
|
||||
|
||||
# Group unread
|
||||
group_count = 0
|
||||
memberships = db.query(MessageGroupMember).filter(
|
||||
MessageGroupMember.user_id == current_user.id
|
||||
).all()
|
||||
for m in memberships:
|
||||
q = db.query(func.count(GroupMessage.id)).filter(
|
||||
GroupMessage.group_id == m.group_id,
|
||||
GroupMessage.sender_id != current_user.id
|
||||
)
|
||||
if m.last_read_at:
|
||||
q = q.filter(GroupMessage.created_at > m.last_read_at)
|
||||
group_count += q.scalar() or 0
|
||||
|
||||
return jsonify({'count': pm_count + group_count})
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
127
database.py
127
database.py
@ -2319,11 +2319,12 @@ class PrivateMessage(Base):
|
||||
|
||||
|
||||
class MessageAttachment(Base):
|
||||
"""Załączniki do wiadomości prywatnych"""
|
||||
"""Załączniki do wiadomości prywatnych i grupowych"""
|
||||
__tablename__ = 'message_attachments'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE'), nullable=False)
|
||||
message_id = Column(Integer, ForeignKey('private_messages.id', ondelete='CASCADE')) # nullable now
|
||||
group_message_id = Column(Integer, ForeignKey('group_message.id', ondelete='CASCADE')) # NEW
|
||||
filename = Column(String(255), nullable=False) # original filename
|
||||
stored_filename = Column(String(255), nullable=False) # UUID-based on disk
|
||||
file_size = Column(Integer, nullable=False) # bytes
|
||||
@ -2333,6 +2334,91 @@ class MessageAttachment(Base):
|
||||
message = relationship('PrivateMessage', backref=backref('attachments', cascade='all, delete-orphan'))
|
||||
|
||||
|
||||
# ============================================================
|
||||
# GROUP MESSAGES
|
||||
# ============================================================
|
||||
|
||||
class MessageGroup(Base):
|
||||
"""Grupa czatowa — nazwana lub ad-hoc"""
|
||||
__tablename__ = 'message_group'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(255))
|
||||
description = Column(Text)
|
||||
owner_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
||||
is_named = Column(Boolean, default=False, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
owner = relationship('User', foreign_keys=[owner_id])
|
||||
members = relationship('MessageGroupMember', backref='group', cascade='all, delete-orphan')
|
||||
messages = relationship('GroupMessage', backref='group', cascade='all, delete-orphan',
|
||||
order_by='GroupMessage.created_at')
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
"""Nazwa wyświetlana — nazwa grupy lub lista imion uczestników"""
|
||||
if self.name:
|
||||
return self.name
|
||||
names = [m.user.name or m.user.email.split('@')[0] for m in self.members if m.user]
|
||||
return ', '.join(sorted(names)[:4]) + (f' +{len(names)-4}' if len(names) > 4 else '')
|
||||
|
||||
@property
|
||||
def member_count(self):
|
||||
return len(self.members)
|
||||
|
||||
@property
|
||||
def last_message(self):
|
||||
"""Ostatnia wiadomość w grupie"""
|
||||
if self.messages:
|
||||
return self.messages[-1]
|
||||
return None
|
||||
|
||||
|
||||
class MessageGroupMember(Base):
|
||||
"""Członkostwo w grupie czatowej"""
|
||||
__tablename__ = 'message_group_member'
|
||||
|
||||
group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), primary_key=True)
|
||||
user_id = Column(Integer, ForeignKey('users.id', ondelete='CASCADE'), primary_key=True)
|
||||
role = Column(String(20), nullable=False, default='member') # owner, moderator, member
|
||||
last_read_at = Column(DateTime)
|
||||
joined_at = Column(DateTime, default=datetime.now)
|
||||
added_by_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||||
|
||||
user = relationship('User', foreign_keys=[user_id])
|
||||
added_by = relationship('User', foreign_keys=[added_by_id])
|
||||
|
||||
@property
|
||||
def is_owner(self):
|
||||
return self.role == 'owner'
|
||||
|
||||
@property
|
||||
def is_moderator(self):
|
||||
return self.role == 'moderator'
|
||||
|
||||
@property
|
||||
def can_manage_members(self):
|
||||
return self.role in ('owner', 'moderator')
|
||||
|
||||
|
||||
class GroupMessage(Base):
|
||||
"""Wiadomość w grupie czatowej"""
|
||||
__tablename__ = 'group_message'
|
||||
|
||||
id = Column(Integer, primary_key=True)
|
||||
group_id = Column(Integer, ForeignKey('message_group.id', ondelete='CASCADE'), nullable=False)
|
||||
sender_id = Column(Integer, ForeignKey('users.id', ondelete='SET NULL'))
|
||||
content = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
|
||||
sender = relationship('User', foreign_keys=[sender_id])
|
||||
attachments = relationship('MessageAttachment',
|
||||
foreign_keys='MessageAttachment.group_message_id',
|
||||
backref='group_message',
|
||||
cascade='all, delete-orphan')
|
||||
|
||||
|
||||
# ============================================================
|
||||
# B2B CLASSIFIEDS
|
||||
# ============================================================
|
||||
@ -4104,7 +4190,7 @@ class CompanyPKD(Base):
|
||||
class CompanyFinancialReport(Base):
|
||||
"""
|
||||
Financial reports (sprawozdania finansowe) filed with KRS.
|
||||
Tracks report periods and filing dates.
|
||||
Tracks report periods, filing dates, and key financial figures.
|
||||
"""
|
||||
__tablename__ = 'company_financial_reports'
|
||||
|
||||
@ -4116,8 +4202,21 @@ class CompanyFinancialReport(Base):
|
||||
report_type = Column(String(50), default='annual') # annual, quarterly
|
||||
source = Column(String(50), default='ekrs')
|
||||
|
||||
# Financial data (in PLN)
|
||||
revenue = Column(Numeric(15, 2)) # Przychody netto ze sprzedaży
|
||||
operating_profit = Column(Numeric(15, 2)) # Zysk/strata z działalności operacyjnej
|
||||
net_profit = Column(Numeric(15, 2)) # Zysk/strata netto
|
||||
total_assets = Column(Numeric(15, 2)) # Suma aktywów
|
||||
equity = Column(Numeric(15, 2)) # Kapitał własny
|
||||
liabilities = Column(Numeric(15, 2)) # Zobowiązania
|
||||
employees_count = Column(Integer) # Średnia liczba zatrudnionych
|
||||
|
||||
# Company size classification: micro, small, medium, large
|
||||
size_class = Column(String(20))
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.now)
|
||||
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# Relationship
|
||||
company = relationship('Company', backref='financial_reports')
|
||||
@ -4126,6 +4225,28 @@ class CompanyFinancialReport(Base):
|
||||
UniqueConstraint('company_id', 'period_start', 'period_end', 'report_type', name='uq_company_financial_report'),
|
||||
)
|
||||
|
||||
def classify_size(self):
|
||||
"""Classify company size based on EU criteria (revenue + employees + assets).
|
||||
micro: <2M EUR revenue, <10 employees, <2M EUR assets
|
||||
small: <10M EUR revenue, <50 employees, <10M EUR assets
|
||||
medium: <50M EUR revenue, <250 employees, <43M EUR assets
|
||||
large: above medium
|
||||
EUR/PLN ~4.3
|
||||
"""
|
||||
rev = float(self.revenue or 0)
|
||||
emp = self.employees_count or 0
|
||||
assets = float(self.total_assets or 0)
|
||||
|
||||
# PLN thresholds (approx EUR * 4.3)
|
||||
if rev < 8_600_000 and emp < 10 and assets < 8_600_000:
|
||||
return 'micro'
|
||||
elif rev < 43_000_000 and emp < 50 and assets < 43_000_000:
|
||||
return 'small'
|
||||
elif rev < 215_000_000 and emp < 250 and assets < 185_000_000:
|
||||
return 'medium'
|
||||
else:
|
||||
return 'large'
|
||||
|
||||
def __repr__(self):
|
||||
return f'<CompanyFinancialReport {self.period_start} - {self.period_end}>'
|
||||
|
||||
|
||||
59
database/migrations/088_message_groups.sql
Normal file
59
database/migrations/088_message_groups.sql
Normal file
@ -0,0 +1,59 @@
|
||||
-- 088_message_groups.sql
|
||||
-- Group messaging tables + alter message_attachments
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- Group/channel for multi-person chats
|
||||
CREATE TABLE message_group (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255),
|
||||
description TEXT,
|
||||
owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
is_named BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_message_group_owner ON message_group(owner_id);
|
||||
|
||||
-- Group membership with roles
|
||||
CREATE TABLE message_group_member (
|
||||
group_id INTEGER NOT NULL REFERENCES message_group(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role VARCHAR(20) NOT NULL DEFAULT 'member' CHECK (role IN ('owner', 'moderator', 'member')),
|
||||
last_read_at TIMESTAMP,
|
||||
joined_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
added_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
PRIMARY KEY (group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_message_group_member_user ON message_group_member(user_id);
|
||||
|
||||
-- Messages within a group
|
||||
CREATE TABLE group_message (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES message_group(id) ON DELETE CASCADE,
|
||||
sender_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_group_message_group ON group_message(group_id);
|
||||
CREATE INDEX idx_group_message_created ON group_message(group_id, created_at);
|
||||
|
||||
-- Extend message_attachments to support group messages
|
||||
ALTER TABLE message_attachments ALTER COLUMN message_id DROP NOT NULL;
|
||||
ALTER TABLE message_attachments ADD COLUMN group_message_id INTEGER REFERENCES group_message(id) ON DELETE CASCADE;
|
||||
ALTER TABLE message_attachments ADD CONSTRAINT chk_attachment_one_parent
|
||||
CHECK ((message_id IS NOT NULL) != (group_message_id IS NOT NULL));
|
||||
|
||||
CREATE INDEX idx_message_attachments_group ON message_attachments(group_message_id) WHERE group_message_id IS NOT NULL;
|
||||
|
||||
-- Grants
|
||||
GRANT ALL ON TABLE message_group TO nordabiz_app;
|
||||
GRANT ALL ON TABLE message_group_member TO nordabiz_app;
|
||||
GRANT ALL ON TABLE group_message TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE message_group_id_seq TO nordabiz_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE group_message_id_seq TO nordabiz_app;
|
||||
|
||||
COMMIT;
|
||||
1410
docs/superpowers/plans/2026-03-20-group-messages-and-search.md
Normal file
1410
docs/superpowers/plans/2026-03-20-group-messages-and-search.md
Normal file
File diff suppressed because it is too large
Load Diff
609
templates/messages/group_compose.html
Normal file
609
templates/messages/group_compose.html
Normal file
@ -0,0 +1,609 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Nowa grupa - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
|
||||
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.quill-container {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.quill-container .ql-toolbar {
|
||||
border-top-left-radius: var(--radius);
|
||||
border-top-right-radius: var(--radius);
|
||||
}
|
||||
.quill-container .ql-container {
|
||||
border-bottom-left-radius: var(--radius);
|
||||
border-bottom-right-radius: var(--radius);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
.quill-container .ql-editor {
|
||||
min-height: 200px;
|
||||
}
|
||||
.quill-container .ql-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
margin: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.compose-container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.compose-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.compose-header h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.compose-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-xl);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-base);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Member picker */
|
||||
.member-picker {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.selected-members {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-xs);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
min-height: 44px;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.selected-members:empty {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.member-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 16px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.member-pill-avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.member-pill-initial {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.member-pill-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.7);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.member-pill-remove:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.member-search-box {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.member-search-box input {
|
||||
width: 100%;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: var(--font-size-sm);
|
||||
padding: 4px 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.member-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.member-list-item:hover {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.member-list-item.selected {
|
||||
background: rgba(37, 99, 235, 0.06);
|
||||
}
|
||||
|
||||
.member-list-item input[type="checkbox"] {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-list-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-list-initial {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-list-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.member-list-name {
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.member-list-company {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.member-list-empty {
|
||||
padding: var(--spacing-md);
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-secondary);
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.compose-card {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="compose-container">
|
||||
<a href="{{ url_for('messages_inbox') }}" class="back-link">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Powrot do wiadomosci
|
||||
</a>
|
||||
|
||||
<div class="compose-header">
|
||||
<h1>Nowa grupa</h1>
|
||||
</div>
|
||||
|
||||
<div class="compose-card">
|
||||
<form method="POST" action="{{ url_for('messages.group_create') }}" enctype="multipart/form-data" id="group-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div id="members-hidden-inputs"></div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="group-name">Nazwa grupy (opcjonalnie)</label>
|
||||
<input type="text" id="group-name" name="name" maxlength="100" placeholder="np. Komisja ds. PEJ">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Uczestnicy *</label>
|
||||
<div class="member-picker">
|
||||
<div class="selected-members" id="selected-members">
|
||||
<span style="color: var(--text-secondary); font-size: var(--font-size-sm);" id="placeholder-text">Wybierz uczestnikow z listy ponizej...</span>
|
||||
</div>
|
||||
<div class="member-search-box">
|
||||
<input type="text" id="member-search" placeholder="Szukaj po imieniu, nazwisku lub firmie..." autocomplete="off">
|
||||
</div>
|
||||
<div class="member-list" id="member-list"></div>
|
||||
<div class="selected-count" id="selected-count">Wybrano: 0 osob</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Pierwsza wiadomosc *</label>
|
||||
<div id="quill-content" class="quill-container" style="min-height: 200px; background: var(--surface);"></div>
|
||||
<textarea id="content" name="content" style="display:none;" required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Zalaczniki (maks. 3 pliki, 15MB lacznie)</label>
|
||||
<div class="file-upload-zone" id="file-drop-zone" style="border: 2px dashed var(--border-color); border-radius: var(--radius); padding: 20px; text-align: center; cursor: pointer; transition: border-color 0.2s;">
|
||||
<input type="file" name="attachments" id="file-input" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="display: none;">
|
||||
<p style="margin: 0; color: var(--text-secondary); font-size: var(--font-size-sm);">
|
||||
Przeciagnij pliki tutaj lub <a href="#" onclick="document.getElementById('file-input').click(); return false;" style="color: var(--primary);">wybierz z dysku</a>
|
||||
</p>
|
||||
<p style="margin: 4px 0 0; color: var(--text-secondary); font-size: var(--font-size-xs);">
|
||||
JPG, PNG, GIF (5MB) · PDF, DOCX, XLSX (10MB)
|
||||
</p>
|
||||
</div>
|
||||
<div id="file-list" style="margin-top: 8px;"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">Utworz grupe</button>
|
||||
<a href="{{ url_for('messages_inbox') }}" class="btn btn-secondary">Anuluj</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
/* Member picker */
|
||||
(function() {
|
||||
var users = [
|
||||
{% for user in users %}
|
||||
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, companyName: {{ (user._company_name or '') | tojson }}, avatarPath: {{ (user.avatar_path or '') | tojson }}}{{ ',' if not loop.last }}
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
var selectedIds = new Set();
|
||||
var searchInput = document.getElementById('member-search');
|
||||
var listDiv = document.getElementById('member-list');
|
||||
var selectedDiv = document.getElementById('selected-members');
|
||||
var countDiv = document.getElementById('selected-count');
|
||||
var hiddenInputsDiv = document.getElementById('members-hidden-inputs');
|
||||
var placeholderText = document.getElementById('placeholder-text');
|
||||
|
||||
function normalize(str) {
|
||||
return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
|
||||
function filterUsers(query) {
|
||||
if (!query) return users;
|
||||
var q = normalize(query);
|
||||
return users.filter(function(u) {
|
||||
return normalize(u.name).indexOf(q) !== -1 ||
|
||||
normalize(u.companyName).indexOf(q) !== -1 ||
|
||||
normalize(u.email).indexOf(q) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
var query = searchInput.value.trim();
|
||||
var filtered = filterUsers(query);
|
||||
|
||||
if (filtered.length === 0) {
|
||||
listDiv.innerHTML = '<div class="member-list-empty">Nie znaleziono</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listDiv.innerHTML = filtered.map(function(u) {
|
||||
var checked = selectedIds.has(u.id) ? 'checked' : '';
|
||||
var selectedClass = selectedIds.has(u.id) ? ' selected' : '';
|
||||
var initial = (u.name || u.email)[0].toUpperCase();
|
||||
var avatarHtml = u.avatarPath
|
||||
? '<img src="' + u.avatarPath + '" class="member-list-avatar" alt="">'
|
||||
: '<div class="member-list-initial">' + initial + '</div>';
|
||||
var companyHtml = u.companyName ? '<div class="member-list-company">' + u.companyName + '</div>' : '';
|
||||
|
||||
return '<label class="member-list-item' + selectedClass + '" data-user-id="' + u.id + '">' +
|
||||
'<input type="checkbox" ' + checked + ' data-id="' + u.id + '" style="width:auto;">' +
|
||||
avatarHtml +
|
||||
'<div class="member-list-info">' +
|
||||
'<div class="member-list-name">' + u.name + '</div>' +
|
||||
companyHtml +
|
||||
'</div>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderPills() {
|
||||
if (selectedIds.size === 0) {
|
||||
selectedDiv.innerHTML = '<span style="color: var(--text-secondary); font-size: var(--font-size-sm);" id="placeholder-text">Wybierz uczestnikow z listy ponizej...</span>';
|
||||
} else {
|
||||
var html = '';
|
||||
selectedIds.forEach(function(id) {
|
||||
var user = users.find(function(u) { return u.id === id; });
|
||||
if (!user) return;
|
||||
var initial = (user.name || user.email)[0].toUpperCase();
|
||||
var avatarHtml = user.avatarPath
|
||||
? '<img src="' + user.avatarPath + '" class="member-pill-avatar" alt="">'
|
||||
: '<div class="member-pill-initial">' + initial + '</div>';
|
||||
html += '<span class="member-pill">' +
|
||||
avatarHtml +
|
||||
'<span>' + user.name + '</span>' +
|
||||
'<button type="button" class="member-pill-remove" data-id="' + id + '" title="Usun">✕</button>' +
|
||||
'</span>';
|
||||
});
|
||||
selectedDiv.innerHTML = html;
|
||||
}
|
||||
countDiv.textContent = 'Wybrano: ' + selectedIds.size + ' osob';
|
||||
}
|
||||
|
||||
function updateHiddenInputs() {
|
||||
hiddenInputsDiv.innerHTML = '';
|
||||
selectedIds.forEach(function(id) {
|
||||
var input = document.createElement('input');
|
||||
input.type = 'hidden';
|
||||
input.name = 'members';
|
||||
input.value = id;
|
||||
hiddenInputsDiv.appendChild(input);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleUser(id) {
|
||||
if (selectedIds.has(id)) {
|
||||
selectedIds.delete(id);
|
||||
} else {
|
||||
selectedIds.add(id);
|
||||
}
|
||||
renderPills();
|
||||
updateHiddenInputs();
|
||||
renderList();
|
||||
}
|
||||
|
||||
listDiv.addEventListener('change', function(e) {
|
||||
if (e.target.type === 'checkbox') {
|
||||
toggleUser(parseInt(e.target.dataset.id));
|
||||
}
|
||||
});
|
||||
|
||||
selectedDiv.addEventListener('click', function(e) {
|
||||
var btn = e.target.closest('.member-pill-remove');
|
||||
if (btn) {
|
||||
toggleUser(parseInt(btn.dataset.id));
|
||||
}
|
||||
});
|
||||
|
||||
searchInput.addEventListener('input', renderList);
|
||||
|
||||
renderList();
|
||||
|
||||
/* Form validation */
|
||||
document.getElementById('group-form').addEventListener('submit', function(e) {
|
||||
if (selectedIds.size < 1) {
|
||||
e.preventDefault();
|
||||
alert('Wybierz co najmniej jednego uczestnika.');
|
||||
return;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
/* File attachment handling */
|
||||
(function() {
|
||||
var dropZone = document.getElementById('file-drop-zone');
|
||||
var fileInput = document.getElementById('file-input');
|
||||
var fileList = document.getElementById('file-list');
|
||||
if (!dropZone) return;
|
||||
|
||||
dropZone.addEventListener('click', function(e) {
|
||||
if (e.target.tagName !== 'A') fileInput.click();
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragover', function(e) {
|
||||
e.preventDefault();
|
||||
dropZone.style.borderColor = 'var(--primary)';
|
||||
});
|
||||
|
||||
dropZone.addEventListener('dragleave', function() {
|
||||
dropZone.style.borderColor = 'var(--border-color)';
|
||||
});
|
||||
|
||||
dropZone.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
dropZone.style.borderColor = 'var(--border-color)';
|
||||
var dt = new DataTransfer();
|
||||
Array.from(e.dataTransfer.files).forEach(function(f) { dt.items.add(f); });
|
||||
Array.from(fileInput.files).forEach(function(f) { dt.items.add(f); });
|
||||
fileInput.files = dt.files;
|
||||
updateFileList();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', updateFileList);
|
||||
|
||||
function updateFileList() {
|
||||
var files = Array.from(fileInput.files);
|
||||
if (files.length === 0) { fileList.innerHTML = ''; return; }
|
||||
fileList.innerHTML = files.map(function(f, i) {
|
||||
var sizeMB = (f.size / 1024 / 1024).toFixed(1);
|
||||
return '<div style="display: flex; align-items: center; gap: 8px; padding: 6px 0; font-size: var(--font-size-sm);">' +
|
||||
'<span style="color: var(--text-secondary);">📎</span> ' +
|
||||
'<span>' + f.name + '</span> ' +
|
||||
'<span style="color: var(--text-secondary);">(' + sizeMB + ' MB)</span> ' +
|
||||
'<a href="#" onclick="removeFile(' + i + '); return false;" style="color: var(--danger); margin-left: auto;">✕</a>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
window.removeFile = function(index) {
|
||||
var dt = new DataTransfer();
|
||||
Array.from(fileInput.files).forEach(function(f, i) {
|
||||
if (i !== index) dt.items.add(f);
|
||||
});
|
||||
fileInput.files = dt.files;
|
||||
updateFileList();
|
||||
};
|
||||
})();
|
||||
|
||||
/* Quill editor for message content */
|
||||
(function() {
|
||||
var csrfToken = '{{ csrf_token() }}';
|
||||
var quill = new Quill('#quill-content', {
|
||||
theme: 'snow',
|
||||
placeholder: 'Napisz wiadomosc...',
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: [
|
||||
['bold', 'italic'],
|
||||
[{'list': 'ordered'}, {'list': 'bullet'}],
|
||||
['link', 'image'],
|
||||
['clean']
|
||||
],
|
||||
handlers: {
|
||||
image: function() {
|
||||
var input = document.createElement('input');
|
||||
input.setAttribute('type', 'file');
|
||||
input.setAttribute('accept', 'image/*');
|
||||
input.click();
|
||||
input.onchange = function() {
|
||||
if (input.files && input.files[0]) {
|
||||
uploadImage(input.files[0]);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function uploadImage(file) {
|
||||
var fd = new FormData();
|
||||
fd.append('image', file);
|
||||
fetch('/api/messages/upload-image', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': csrfToken},
|
||||
body: fd
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.url) {
|
||||
var range = quill.getSelection(true);
|
||||
quill.insertEmbed(range.index, 'image', data.url);
|
||||
quill.setSelection(range.index + 1);
|
||||
} else {
|
||||
alert(data.error || 'Blad uploadu');
|
||||
}
|
||||
})
|
||||
.catch(function() { alert('Blad polaczenia'); });
|
||||
}
|
||||
|
||||
quill.root.addEventListener('paste', function(e) {
|
||||
var items = (e.clipboardData || {}).items || [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
var file = items[i].getAsFile();
|
||||
if (file) uploadImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
var textarea = document.getElementById('content');
|
||||
quill.on('text-change', function() {
|
||||
var html = quill.root.innerHTML;
|
||||
textarea.value = (html === '<p><br></p>') ? '' : html;
|
||||
});
|
||||
|
||||
document.getElementById('group-form').addEventListener('submit', function(e) {
|
||||
var html = quill.root.innerHTML;
|
||||
textarea.value = (html === '<p><br></p>') ? '' : html;
|
||||
if (!textarea.value.trim()) {
|
||||
e.preventDefault();
|
||||
alert('Tresc wiadomosci jest wymagana.');
|
||||
}
|
||||
});
|
||||
})();
|
||||
{% endblock %}
|
||||
562
templates/messages/group_manage.html
Normal file
562
templates/messages/group_manage.html
Normal file
@ -0,0 +1,562 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Zarzadzanie grupa - {{ group.display_name }} - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.manage-container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.manage-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.manage-header h1 {
|
||||
font-size: var(--font-size-2xl);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.manage-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
padding: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.manage-card h2 {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-base);
|
||||
transition: var(--transition);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
/* Members table */
|
||||
.members-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.members-table th {
|
||||
text-align: left;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 2px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.members-table td {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-bottom: 1px solid var(--border-color, #f3f4f6);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.members-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.member-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-initial {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-info-name {
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.member-info-email {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.role-badge.owner {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.role-badge.moderator {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.role-badge.member {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
background: var(--background);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-action.danger {
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.btn-action.danger:hover {
|
||||
background: #fef2f2;
|
||||
border-color: #dc2626;
|
||||
}
|
||||
|
||||
.btn-action.moderator-toggle {
|
||||
color: var(--primary);
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
.btn-action.moderator-toggle:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Add member section */
|
||||
.add-member-section {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.add-member-section h3 {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.add-member-autocomplete {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.add-member-autocomplete input[type="text"] {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-base);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.add-member-autocomplete input[type="text"]:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.add-autocomplete-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-top: none;
|
||||
border-radius: 0 0 var(--radius) var(--radius);
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.add-autocomplete-results.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.add-autocomplete-item {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.add-autocomplete-item:hover {
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.add-autocomplete-item-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add-autocomplete-item-initial {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.add-autocomplete-item-name {
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.add-autocomplete-item-company {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.add-autocomplete-no-results {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.manage-card {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
.members-table th:last-child,
|
||||
.members-table td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
.action-buttons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="manage-container">
|
||||
<a href="{{ url_for('messages.group_view', group_id=group.id) }}" class="back-link">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Powrot do grupy
|
||||
</a>
|
||||
|
||||
<div class="manage-header">
|
||||
<h1>Zarzadzanie grupa</h1>
|
||||
</div>
|
||||
|
||||
{# ===== GROUP INFO EDIT (owner only) ===== #}
|
||||
{% if membership.is_owner %}
|
||||
<div class="manage-card">
|
||||
<h2>Informacje o grupie</h2>
|
||||
<form method="POST" action="{{ url_for('messages.group_update', group_id=group.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<label for="group-name">Nazwa grupy</label>
|
||||
<input type="text" id="group-name" name="name" maxlength="100" value="{{ group.name or '' }}" placeholder="np. Komisja ds. PEJ">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="group-description">Opis (opcjonalnie)</label>
|
||||
<textarea id="group-description" name="description" maxlength="500" placeholder="Krotki opis grupy...">{{ group.description or '' }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Zapisz zmiany</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== MEMBERS TABLE ===== #}
|
||||
<div class="manage-card">
|
||||
<h2>Uczestnicy ({{ members|length }})</h2>
|
||||
|
||||
<table class="members-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Osoba</th>
|
||||
<th>Rola</th>
|
||||
<th>Akcje</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for m in members %}
|
||||
<tr>
|
||||
<td>
|
||||
<div class="member-cell">
|
||||
{% if m.user.avatar_path %}
|
||||
<img src="{{ m.user.avatar_path }}" class="member-avatar" alt="">
|
||||
{% else %}
|
||||
<div class="member-initial">{{ (m.user.name or m.user.email)[0].upper() }}</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<div class="member-info-name">{{ m.user.name or m.user.email.split('@')[0] }}</div>
|
||||
{% if m.user.privacy_show_email != False %}
|
||||
<div class="member-info-email">{{ m.user.email }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{% if m.is_owner %}
|
||||
<span class="role-badge owner">Wlasciciel</span>
|
||||
{% elif m.is_moderator %}
|
||||
<span class="role-badge moderator">Moderator</span>
|
||||
{% else %}
|
||||
<span class="role-badge member">Uczestnik</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
{% if m.user_id != current_user.id and not m.is_owner %}
|
||||
{% if membership.is_owner %}
|
||||
{# Owner can toggle moderator and remove #}
|
||||
<form method="POST" action="{{ url_for('messages.group_toggle_moderator', group_id=group.id, user_id=m.user_id) }}" style="display:inline;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-action moderator-toggle" title="{% if m.is_moderator %}Odbierz moderatora{% else %}Nadaj moderatora{% endif %}">
|
||||
{% if m.is_moderator %}
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
Odbierz mod.
|
||||
{% else %}
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></svg>
|
||||
Nadaj mod.
|
||||
{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="{{ url_for('messages.group_remove_member', group_id=group.id, user_id=m.user_id) }}" style="display:inline;" onsubmit="return confirm('Czy na pewno chcesz usunac tego uczestnika z grupy?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-action danger">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Usun
|
||||
</button>
|
||||
</form>
|
||||
{% elif membership.is_moderator and not m.is_moderator %}
|
||||
{# Moderator can remove members only #}
|
||||
<form method="POST" action="{{ url_for('messages.group_remove_member', group_id=group.id, user_id=m.user_id) }}" style="display:inline;" onsubmit="return confirm('Czy na pewno chcesz usunac tego uczestnika z grupy?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn-action danger">
|
||||
<svg width="12" height="12" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
|
||||
Usun
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% elif m.user_id == current_user.id %}
|
||||
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Ty</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{# ===== ADD MEMBER ===== #}
|
||||
<div class="add-member-section">
|
||||
<h3>Dodaj uczestnika</h3>
|
||||
<div class="add-member-autocomplete" id="add-member-autocomplete">
|
||||
<input type="text" id="add-member-search" placeholder="Wpisz imie, nazwisko lub email..." autocomplete="off">
|
||||
<div id="add-autocomplete-results" class="add-autocomplete-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
/* Add member autocomplete */
|
||||
(function() {
|
||||
var availableUsers = [
|
||||
{% for user in available_users %}
|
||||
{id: {{ user.id }}, name: {{ (user.name or user.email.split('@')[0]) | tojson }}, email: {{ user.email | tojson }}, companyName: {{ (user._company_name or '') | tojson }}, avatarPath: {{ (user.avatar_path or '') | tojson }}}{{ ',' if not loop.last }}
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
var searchInput = document.getElementById('add-member-search');
|
||||
var resultsDiv = document.getElementById('add-autocomplete-results');
|
||||
var autocompleteDiv = document.getElementById('add-member-autocomplete');
|
||||
var groupId = {{ group.id }};
|
||||
var csrfToken = '{{ csrf_token() }}';
|
||||
|
||||
function normalize(str) {
|
||||
return str.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||
}
|
||||
|
||||
function filterUsers(query) {
|
||||
if (!query) return [];
|
||||
var q = normalize(query);
|
||||
return availableUsers.filter(function(u) {
|
||||
return normalize(u.name).indexOf(q) !== -1 ||
|
||||
normalize(u.email).indexOf(q) !== -1 ||
|
||||
normalize(u.companyName).indexOf(q) !== -1;
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(matches) {
|
||||
if (matches.length === 0) {
|
||||
resultsDiv.innerHTML = '<div class="add-autocomplete-no-results">Nie znaleziono</div>';
|
||||
resultsDiv.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
resultsDiv.innerHTML = matches.map(function(u) {
|
||||
var initial = (u.name || u.email)[0].toUpperCase();
|
||||
var avatarHtml = u.avatarPath
|
||||
? '<img src="' + u.avatarPath + '" class="add-autocomplete-item-avatar" alt="">'
|
||||
: '<div class="add-autocomplete-item-initial">' + initial + '</div>';
|
||||
var companyHtml = u.companyName ? '<div class="add-autocomplete-item-company">' + u.companyName + '</div>' : '';
|
||||
return '<div class="add-autocomplete-item" data-id="' + u.id + '">' +
|
||||
avatarHtml +
|
||||
'<div>' +
|
||||
'<div class="add-autocomplete-item-name">' + u.name + '</div>' +
|
||||
companyHtml +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
resultsDiv.classList.add('visible');
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', function() {
|
||||
var q = this.value.trim();
|
||||
if (q.length === 0) {
|
||||
resultsDiv.classList.remove('visible');
|
||||
return;
|
||||
}
|
||||
renderResults(filterUsers(q));
|
||||
});
|
||||
|
||||
searchInput.addEventListener('focus', function() {
|
||||
if (this.value.trim().length > 0) {
|
||||
renderResults(filterUsers(this.value.trim()));
|
||||
}
|
||||
});
|
||||
|
||||
resultsDiv.addEventListener('click', function(e) {
|
||||
var item = e.target.closest('.add-autocomplete-item');
|
||||
if (item) {
|
||||
var userId = item.dataset.id;
|
||||
/* Submit add member via hidden form */
|
||||
var form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = '{{ url_for("messages.group_add_member", group_id=group.id) }}';
|
||||
|
||||
var csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = csrfToken;
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
var userInput = document.createElement('input');
|
||||
userInput.type = 'hidden';
|
||||
userInput.name = 'user_id';
|
||||
userInput.value = userId;
|
||||
form.appendChild(userInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!autocompleteDiv.contains(e.target)) {
|
||||
resultsDiv.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
})();
|
||||
{% endblock %}
|
||||
549
templates/messages/group_view.html
Normal file
549
templates/messages/group_view.html
Normal file
@ -0,0 +1,549 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ group.display_name }} - Norda Biznes Partner{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/quill.snow.css') }}">
|
||||
<script src="{{ url_for('static', filename='js/vendor/quill.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
.group-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--text-secondary);
|
||||
text-decoration: none;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Group header */
|
||||
.group-header {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.group-header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.group-member-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 10px;
|
||||
background: #eff6ff;
|
||||
border-radius: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.group-manage-link {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.group-manage-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Members collapsible */
|
||||
.members-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin-top: var(--spacing-md);
|
||||
padding: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.members-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.members-toggle svg {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.members-toggle.open svg {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.members-list {
|
||||
display: none;
|
||||
margin-top: var(--spacing-sm);
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--border-color, #f3f4f6);
|
||||
}
|
||||
|
||||
.members-list.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-initial {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.role-badge.owner {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.role-badge.moderator {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.messages-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.group-message {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-initial {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: var(--font-size-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-initial.is-me {
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.msg-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing-sm);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.msg-sender {
|
||||
font-weight: 500;
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.msg-time {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.msg-content {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.msg-content img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
margin: var(--spacing-xs) 0;
|
||||
}
|
||||
|
||||
.msg-content a {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.msg-content p {
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.msg-content p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.msg-attachments {
|
||||
margin-top: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.msg-attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Reply form */
|
||||
.reply-section {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
}
|
||||
|
||||
.reply-section h3 {
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: 600;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.quill-container {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.quill-container .ql-toolbar {
|
||||
border-top-left-radius: var(--radius);
|
||||
border-top-right-radius: var(--radius);
|
||||
}
|
||||
.quill-container .ql-container {
|
||||
border-bottom-left-radius: var(--radius);
|
||||
border-bottom-right-radius: var(--radius);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
.quill-container .ql-editor {
|
||||
min-height: 120px;
|
||||
}
|
||||
.quill-container .ql-editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
margin: var(--spacing-sm) 0;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--spacing-2xl) var(--spacing-lg);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.group-header {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
.group-header-top {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.reply-section {
|
||||
padding: var(--spacing-md);
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="group-container">
|
||||
<a href="{{ url_for('messages_inbox') }}" class="back-link">
|
||||
<svg width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
Powrot do wiadomosci
|
||||
</a>
|
||||
|
||||
{# ===== GROUP HEADER ===== #}
|
||||
<div class="group-header">
|
||||
<div class="group-header-top">
|
||||
<div class="group-title">
|
||||
{{ group.display_name }}
|
||||
<span class="group-member-count">{{ members|length }}</span>
|
||||
</div>
|
||||
{% if membership.is_owner or membership.is_moderator %}
|
||||
<a href="{{ url_for('messages.group_manage', group_id=group.id) }}" class="group-manage-link">
|
||||
<svg width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24" style="vertical-align: -2px;">
|
||||
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"/>
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
</svg>
|
||||
Zarzadzaj
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button class="members-toggle" id="members-toggle" type="button">
|
||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 24 24"><path d="M8.59 16.59L13.17 12 8.59 7.41 10 6l6 6-6 6z"/></svg>
|
||||
Uczestnicy ({{ members|length }})
|
||||
</button>
|
||||
<div class="members-list" id="members-list">
|
||||
{% for m in members %}
|
||||
<div class="member-item">
|
||||
{% if m.user.avatar_path %}
|
||||
<img src="{{ m.user.avatar_path }}" class="member-avatar" alt="">
|
||||
{% else %}
|
||||
<div class="member-initial">{{ (m.user.name or m.user.email)[0].upper() }}</div>
|
||||
{% endif %}
|
||||
<span class="member-name">
|
||||
{% if m.user_id == current_user.id %}Ty{% else %}{{ m.user.name or m.user.email.split('@')[0] }}{% endif %}
|
||||
</span>
|
||||
{% if m.is_owner %}
|
||||
<span class="role-badge owner">Wlasciciel</span>
|
||||
{% elif m.is_moderator %}
|
||||
<span class="role-badge moderator">Moderator</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== MESSAGES ===== #}
|
||||
<div class="messages-section" id="messages-section">
|
||||
{% if messages %}
|
||||
{% for msg in messages %}
|
||||
<div class="group-message">
|
||||
{% if msg.sender.avatar_path %}
|
||||
<img src="{{ msg.sender.avatar_path }}" class="msg-avatar" alt="">
|
||||
{% else %}
|
||||
<div class="msg-initial {% if msg.sender_id == current_user.id %}is-me{% endif %}">
|
||||
{{ (msg.sender.name or msg.sender.email)[0].upper() }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="msg-bubble">
|
||||
<div class="msg-header">
|
||||
<span class="msg-sender">{% if msg.sender_id == current_user.id %}Ty{% else %}{{ msg.sender.name or msg.sender.email.split('@')[0] }}{% endif %}</span>
|
||||
<span class="msg-time">{{ msg.created_at.strftime('%d.%m.%Y %H:%M') }}</span>
|
||||
</div>
|
||||
<div class="msg-content">{{ msg.content|linkify }}</div>
|
||||
{% if msg.attachments %}
|
||||
<div class="msg-attachments">
|
||||
{% for att in msg.attachments %}
|
||||
{% set is_image = att.mime_type and att.mime_type.startswith('image/') %}
|
||||
<div class="msg-attachment-item">
|
||||
{% if is_image %}
|
||||
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" target="_blank">
|
||||
<img src="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" alt="{{ att.filename }}" style="max-width: 200px; max-height: 120px; border-radius: var(--radius); border: 1px solid var(--border-color, #e5e7eb);">
|
||||
</a>
|
||||
{% else %}
|
||||
<span style="font-size: 16px;">📄</span>
|
||||
<a href="/static/uploads/messages/{{ att.created_at.strftime('%Y/%m') }}/{{ att.stored_filename }}" download="{{ att.filename }}" style="color: var(--primary); font-weight: 500; font-size: var(--font-size-sm);">{{ att.filename }}</a>
|
||||
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">{{ (att.file_size / 1024)|round(0)|int }} KB</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Brak wiadomosci w grupie</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# ===== REPLY FORM ===== #}
|
||||
<div class="reply-section">
|
||||
<h3>Napisz wiadomosc</h3>
|
||||
<form method="POST" action="{{ url_for('messages.group_send', group_id=group.id) }}" enctype="multipart/form-data" id="reply-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="form-group">
|
||||
<div id="quill-reply" class="quill-container" style="background: var(--surface);"></div>
|
||||
<textarea name="content" id="reply-content" style="display:none;" required></textarea>
|
||||
</div>
|
||||
<div class="form-group" style="margin-top: 8px;">
|
||||
<input type="file" name="attachments" multiple accept=".jpg,.jpeg,.png,.gif,.pdf,.docx,.xlsx" style="font-size: var(--font-size-sm);">
|
||||
<span style="font-size: var(--font-size-xs); color: var(--text-secondary);">Maks. 3 pliki, 15MB lacznie</span>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Wyslij</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
/* Members toggle */
|
||||
(function() {
|
||||
var toggle = document.getElementById('members-toggle');
|
||||
var list = document.getElementById('members-list');
|
||||
if (toggle && list) {
|
||||
toggle.addEventListener('click', function() {
|
||||
toggle.classList.toggle('open');
|
||||
list.classList.toggle('visible');
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
/* Auto-scroll to bottom */
|
||||
(function() {
|
||||
var section = document.getElementById('messages-section');
|
||||
if (section && section.children.length > 0) {
|
||||
var lastMsg = section.lastElementChild;
|
||||
if (lastMsg) {
|
||||
lastMsg.scrollIntoView({behavior: 'instant', block: 'end'});
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
/* Quill reply editor */
|
||||
(function() {
|
||||
var replyDiv = document.getElementById('quill-reply');
|
||||
if (!replyDiv) return;
|
||||
|
||||
var csrfToken = '{{ csrf_token() }}';
|
||||
var quill = new Quill('#quill-reply', {
|
||||
theme: 'snow',
|
||||
placeholder: 'Napisz wiadomosc...',
|
||||
modules: {
|
||||
toolbar: {
|
||||
container: [
|
||||
['bold', 'italic'],
|
||||
['link', 'image'],
|
||||
['clean']
|
||||
],
|
||||
handlers: {
|
||||
image: function() {
|
||||
var input = document.createElement('input');
|
||||
input.setAttribute('type', 'file');
|
||||
input.setAttribute('accept', 'image/*');
|
||||
input.click();
|
||||
input.onchange = function() {
|
||||
if (input.files && input.files[0]) uploadImage(input.files[0]);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function uploadImage(file) {
|
||||
var fd = new FormData();
|
||||
fd.append('image', file);
|
||||
fetch('/api/messages/upload-image', {
|
||||
method: 'POST',
|
||||
headers: {'X-CSRFToken': csrfToken},
|
||||
body: fd
|
||||
})
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (data.url) {
|
||||
var range = quill.getSelection(true);
|
||||
quill.insertEmbed(range.index, 'image', data.url);
|
||||
quill.setSelection(range.index + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
quill.root.addEventListener('paste', function(e) {
|
||||
var items = (e.clipboardData || {}).items || [];
|
||||
for (var i = 0; i < items.length; i++) {
|
||||
if (items[i].type.indexOf('image') !== -1) {
|
||||
e.stopImmediatePropagation();
|
||||
e.preventDefault();
|
||||
var file = items[i].getAsFile();
|
||||
if (file) uploadImage(file);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, true);
|
||||
|
||||
var textarea = document.getElementById('reply-content');
|
||||
quill.on('text-change', function() {
|
||||
var html = quill.root.innerHTML;
|
||||
textarea.value = (html === '<p><br></p>') ? '' : html;
|
||||
});
|
||||
|
||||
document.getElementById('reply-form').addEventListener('submit', function(e) {
|
||||
var html = quill.root.innerHTML;
|
||||
textarea.value = (html === '<p><br></p>') ? '' : html;
|
||||
if (!textarea.value.trim()) {
|
||||
e.preventDefault();
|
||||
alert('Tresc wiadomosci jest wymagana.');
|
||||
}
|
||||
});
|
||||
})();
|
||||
{% endblock %}
|
||||
@ -256,6 +256,55 @@
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.messages-search {
|
||||
position: relative;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.messages-search input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) 36px;
|
||||
border: 1.5px solid var(--border-color, #e5e7eb);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-sm);
|
||||
background: white;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.messages-search input:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(46, 72, 114, 0.1);
|
||||
}
|
||||
|
||||
.messages-search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.messages-search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages-search-clear.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.messages-topbar {
|
||||
flex-wrap: wrap;
|
||||
@ -287,15 +336,65 @@
|
||||
<a href="{{ url_for('messages_inbox') }}" class="tab-link active">Odebrane</a>
|
||||
<a href="{{ url_for('messages_sent') }}" class="tab-link">Wysłane</a>
|
||||
</div>
|
||||
<a href="{{ url_for('messages_new') }}" class="btn btn-primary btn-sm">Nowa wiadomość</a>
|
||||
<div style="display: flex; gap: var(--spacing-xs);">
|
||||
<a href="{{ url_for('messages.group_compose') }}" class="btn btn-sm" style="background: white; color: var(--primary); border: 1.5px solid var(--primary); font-weight: 600;">👥 Nowa grupa</a>
|
||||
<a href="{{ url_for('messages_new') }}" class="btn btn-primary btn-sm">Nowa wiadomość</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="messages-search" method="GET" action="{{ url_for('messages.messages_inbox') }}">
|
||||
<span class="messages-search-icon">🔍</span>
|
||||
<input type="text" name="q" value="{{ search_query or '' }}" placeholder="Szukaj w wiadomościach..." autocomplete="off">
|
||||
<button type="button" class="messages-search-clear {% if search_query %}visible{% endif %}" onclick="this.previousElementSibling.value=''; this.form.submit();">✕</button>
|
||||
</form>
|
||||
{% if search_query %}
|
||||
<div style="margin-bottom: var(--spacing-md); color: var(--text-muted); font-size: var(--font-size-sm);">
|
||||
Wyniki wyszukiwania dla: <strong>{{ search_query }}</strong>
|
||||
<a href="{{ url_for('messages.messages_inbox') }}" style="margin-left: var(--spacing-sm); color: var(--primary);">Wyczyść</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="messages-list">
|
||||
{% if group_items %}
|
||||
{% for gi in group_items|sort(attribute='sort_date', reverse=True) %}
|
||||
<a href="{{ url_for('messages.group_view', group_id=gi.group.id) }}" class="message-item {% if gi.unread_count > 0 %}unread{% endif %}">
|
||||
<div class="message-avatar" style="background: #166534; font-size: 14px;">👥</div>
|
||||
<div class="message-body">
|
||||
<div class="message-top-row">
|
||||
<div class="message-subject">
|
||||
{{ gi.group.display_name }}
|
||||
<span class="message-badges">
|
||||
<span style="color: var(--text-secondary); font-size: 11px; background: rgba(46,72,114,0.1); padding: 1px 6px; border-radius: 8px;">{{ gi.group.member_count }} os.</span>
|
||||
</span>
|
||||
</div>
|
||||
<span class="message-date">{{ gi.sort_date.strftime('%d.%m.%Y %H:%M') if gi.sort_date else '' }}</span>
|
||||
</div>
|
||||
<div class="message-bottom-row">
|
||||
{% if gi.last_message %}
|
||||
<span class="message-preview">{{ (gi.last_message.sender.name or 'Ktoś') if gi.last_message.sender else 'Ktoś' }}: {{ gi.last_message.content|striptags|truncate(80) }}</span>
|
||||
{% else %}
|
||||
<span class="message-preview" style="color: var(--text-muted);">Brak wiadomości</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if gi.unread_count > 0 %}
|
||||
<span class="status-pill new-msg">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/></svg>
|
||||
{{ gi.unread_count }} nowe
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if messages %}
|
||||
{% for msg in messages %}
|
||||
<a href="{{ url_for('messages_view', message_id=msg.id) }}" class="message-item {% if not msg.is_read %}unread{% endif %}">
|
||||
<div class="message-avatar">
|
||||
{% if msg.sender.avatar_path %}
|
||||
<img src="{{ url_for('static', filename='uploads/' + msg.sender.avatar_path) }}" style="width: 100%; height: 100%; border-radius: 50%; object-fit: cover;">
|
||||
{% else %}
|
||||
{{ (msg.sender.name or msg.sender.email)[0].upper() }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="message-body">
|
||||
<div class="message-top-row">
|
||||
@ -334,9 +433,11 @@
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if not group_items %}
|
||||
<div class="empty-state">
|
||||
<p>Brak wiadomości w skrzynce</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
|
||||
@ -233,6 +233,55 @@
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.messages-search {
|
||||
position: relative;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.messages-search input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-sm) var(--spacing-md) var(--spacing-sm) 36px;
|
||||
border: 1.5px solid var(--border-color, #e5e7eb);
|
||||
border-radius: var(--radius);
|
||||
font-size: var(--font-size-sm);
|
||||
background: white;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.messages-search input:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(46, 72, 114, 0.1);
|
||||
}
|
||||
|
||||
.messages-search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.messages-search-clear {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
padding: 4px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.messages-search-clear.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.messages-topbar {
|
||||
flex-wrap: wrap;
|
||||
@ -267,6 +316,18 @@
|
||||
<a href="{{ url_for('messages_new') }}" class="btn btn-primary btn-sm">Nowa wiadomość</a>
|
||||
</div>
|
||||
|
||||
<form class="messages-search" method="GET" action="{{ url_for('messages.messages_sent') }}">
|
||||
<span class="messages-search-icon">🔍</span>
|
||||
<input type="text" name="q" value="{{ search_query or '' }}" placeholder="Szukaj w wiadomościach..." autocomplete="off">
|
||||
<button type="button" class="messages-search-clear {% if search_query %}visible{% endif %}" onclick="this.previousElementSibling.value=''; this.form.submit();">✕</button>
|
||||
</form>
|
||||
{% if search_query %}
|
||||
<div style="margin-bottom: var(--spacing-md); color: var(--text-muted); font-size: var(--font-size-sm);">
|
||||
Wyniki wyszukiwania dla: <strong>{{ search_query }}</strong>
|
||||
<a href="{{ url_for('messages.messages_sent') }}" style="margin-left: var(--spacing-sm); color: var(--primary);">Wyczyść</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="messages-list">
|
||||
{% if messages %}
|
||||
{% for msg in messages %}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user