Compare commits

...

10 Commits
Beta ... main

Author SHA1 Message Date
Ronny Grobel
d9236b81c8
Merge pull request #1 from OHV-IT/Alpha
UI: Standardize header heights and sidebar padding - py-3 for all sid…
2025-12-14 13:23:29 +01:00
DGSoft
7d8e839dbe UI: Standardize header heights and sidebar padding - py-3 for all sidebars, py-0.5 for content headers 2025-12-14 13:20:01 +01:00
DGSoft
71bea0ae7d Wrap AuthProvider with ToastProvider so AuthContext can use useToast 2025-12-12 12:55:04 +01:00
DGSoft
fac9b2e4ac Add read-marker line in chat: store last-seen timestamps and render subtle horizontal line after last-read message (channels & DMs) 2025-12-12 12:53:52 +01:00
DGSoft
df394b3b7d Add 'read up to here' marker line in chat messages 2025-12-12 12:45:03 +01:00
DGSoft
f52fb50a39 Add session expiration handling with toast notification and auto-redirect to login 2025-12-12 12:39:34 +01:00
DGSoft
c7cfbad3d8 Remove online status display for own messages in chat components 2025-12-12 12:37:08 +01:00
DGSoft
9a557d28a2 Fix user-status endpoint: resolve TypeError from unhashable User objects in deduplication logic 2025-12-12 11:35:23 +01:00
DGSoft
382d4ac3f0 feat: Restrict user-status endpoint to department members and chat partners
- Only return users from same department as current user
- Include users with existing private chat conversations
- Remove current user from results for privacy
- Improve performance by limiting user list
2025-12-12 11:29:13 +01:00
DGSoft
cfd7068af5 feat: Add blinking envelope icons for unread messages
- Implement unread message indicators with Material-UI icons
- Add BlinkingEnvelope component with theme-compatible colors
- Create UnreadMessagesContext for managing unread states
- Integrate WebSocket message handling for real-time notifications
- Icons only appear for inactive channels/DMs, disappear when opened
- Add test functionality (double-click to mark as unread)
- Fix WebSocket URL handling for production deployment
- Unify WebSocket architecture using presence connection for all messages
2025-12-12 11:26:36 +01:00
61 changed files with 6060 additions and 2139 deletions

35
backend/alembic.ini Normal file
View File

@ -0,0 +1,35 @@
[alembic]
script_location = backend/alembic
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
propagate = 0
[logger_alembic]
level = INFO
handlers = console
qualname = alembic

44
backend/alembic/env.py Normal file
View File

@ -0,0 +1,44 @@
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
import os
import sys
# Add backend path
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.models import SQLModel # noqa: E402
from app.database import engine # noqa: E402
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
fileConfig(config.config_file_name)
target_metadata = SQLModel.metadata
def run_migrations_offline():
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,31 @@
"""create last_seen table
Revision ID: 0001_create_last_seen_table
Revises:
Create Date: 2025-12-12 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0001_create_last_seen_table'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'last_seen',
sa.Column('id', sa.Integer(), primary_key=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('channel_id', sa.Integer(), nullable=True),
sa.Column('dm_user_id', sa.Integer(), nullable=True),
sa.Column('last_seen', sa.DateTime(), nullable=False),
)
op.create_index(op.f('ix_last_seen_last_seen'), 'last_seen', ['last_seen'], unique=False)
def downgrade():
op.drop_index(op.f('ix_last_seen_last_seen'), table_name='last_seen')
op.drop_table('last_seen')

View File

@ -0,0 +1,42 @@
"""Create direct_message_attachment table
Revision ID: 0002
Revises: 0001
Create Date: 2025-12-12 22:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0002'
down_revision = '0001'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create direct_message_attachment table
op.create_table(
'direct_message_attachment',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(), nullable=False),
sa.Column('original_filename', sa.String(), nullable=False),
sa.Column('mime_type', sa.String(), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('file_path', sa.String(), nullable=False),
sa.Column('direct_message_id', sa.Integer(), nullable=False),
sa.Column('uploader_id', sa.Integer(), nullable=True),
sa.Column('webdav_path', sa.String(), nullable=True),
sa.Column('upload_permission', sa.String(), nullable=False, server_default='read'),
sa.Column('is_editable', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('uploaded_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
sa.ForeignKeyConstraint(['direct_message_id'], ['direct_message.id'], ),
sa.ForeignKeyConstraint(['uploader_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
op.drop_table('direct_message_attachment')

View File

@ -0,0 +1,30 @@
"""Add assignee_id to kanban_card table
Revision ID: 0003
Revises: 0001
Create Date: 2024-12-13 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0003'
down_revision = '0001'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Add assignee_id column to kanban_card table
op.add_column('kanban_card', sa.Column('assignee_id', sa.Integer(), nullable=True))
# Add foreign key constraint
op.create_foreign_key('fk_kanban_card_assignee_id', 'kanban_card', 'user', ['assignee_id'], ['id'])
def downgrade() -> None:
# Drop foreign key constraint
op.drop_constraint('fk_kanban_card_assignee_id', 'kanban_card', type_='foreignkey')
# Remove assignee_id column
op.drop_column('kanban_card', 'assignee_id')

View File

@ -0,0 +1,37 @@
"""Add kanban_card_activity_log table
Revision ID: 0004
Revises: 0003
Create Date: 2024-12-13 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0004'
down_revision = '0003'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'kanban_card_activity_log',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('card_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('action', sa.String(), nullable=False),
sa.Column('field_name', sa.String(), nullable=True),
sa.Column('old_value', sa.String(), nullable=True),
sa.Column('new_value', sa.String(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['card_id'], ['kanban_card.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
op.drop_table('kanban_card_activity_log')

View File

@ -0,0 +1,24 @@
"""Add is_archived to kanban_card table
Revision ID: 0005
Revises: 0004
Create Date: 2024-12-13 00:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0005'
down_revision = '0004'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column('kanban_card', sa.Column('is_archived', sa.Boolean(), nullable=False, server_default='false'))
def downgrade() -> None:
op.drop_column('kanban_card', 'is_archived')

View File

@ -1,10 +1,13 @@
from fastapi import FastAPI from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path from pathlib import Path
from app.database import create_db_and_tables from app.database import create_db_and_tables, get_session
from app.config import get_settings from app.config import get_settings
from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban from app.routers import auth, departments, channels, messages, files, websocket, snippets, admin, direct_messages, kanban, last_seen
from app.auth import get_current_user
from app.models import User, DirectMessage, Department
from sqlmodel import Session, select
settings = get_settings() settings = get_settings()
@ -14,13 +17,22 @@ app = FastAPI(
version="1.0.0" version="1.0.0"
) )
try:
from starlette.middleware.proxy_headers import ProxyHeadersMiddleware
# Honor proxy headers (X-Forwarded-For, X-Forwarded-Proto) when behind nginx
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
except ImportError:
import logging
logging.getLogger(__name__).warning("ProxyHeadersMiddleware not available. Start uvicorn with --proxy-headers or install 'starlette' to enable forwarded header handling.")
# CORS middleware # CORS middleware
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=[ allow_origins=[
settings.frontend_url,
"http://localhost:5173", "http://localhost:5173",
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"https://collabrix.apex-project.de", "https://collabrix.apex-project.de",
"http://collabrix.apex-project.de" "http://collabrix.apex-project.de"
], ],
@ -55,6 +67,12 @@ app.include_router(files.router)
app.include_router(snippets.router) app.include_router(snippets.router)
app.include_router(kanban.router) app.include_router(kanban.router)
app.include_router(websocket.router) app.include_router(websocket.router)
app.include_router(last_seen.router)
# Mount uploads directory for file serving
uploads_dir = Path(__file__).parent.parent / "uploads"
uploads_dir.mkdir(parents=True, exist_ok=True)
app.mount("/files", StaticFiles(directory=uploads_dir), name="files")
@app.get("/") @app.get("/")
@ -71,3 +89,73 @@ def read_root():
def health_check(): def health_check():
"""Health check endpoint""" """Health check endpoint"""
return {"status": "healthy"} return {"status": "healthy"}
@app.get("/user-status")
def get_user_statuses(session: Session = Depends(get_session), current_user: User = Depends(get_current_user)):
"""Get online status for users in same department and users with existing private chats"""
from app.websocket import manager
# Get users from same department
department_users = []
if current_user.departments:
dept_ids = [dept.id for dept in current_user.departments]
dept_statement = select(User).where(User.departments.any(Department.id.in_(dept_ids)))
department_users = session.exec(dept_statement).all()
# Get users with existing private chats
# Find all direct messages where current user is sender or receiver
dm_statement = select(DirectMessage).where(
(DirectMessage.sender_id == current_user.id) | (DirectMessage.receiver_id == current_user.id)
)
direct_messages = session.exec(dm_statement).all()
# Extract unique user IDs (excluding current user)
chat_partner_ids = set()
for dm in direct_messages:
if dm.sender_id != current_user.id:
chat_partner_ids.add(dm.sender_id)
if dm.receiver_id != current_user.id:
chat_partner_ids.add(dm.receiver_id)
# Get chat partner users
if chat_partner_ids:
chat_partners_statement = select(User).where(User.id.in_(chat_partner_ids))
chat_partners = session.exec(chat_partners_statement).all()
else:
chat_partners = []
# Combine and deduplicate users by ID
all_user_ids = set()
all_users = []
# Add department users
for user in department_users:
if user.id not in all_user_ids:
all_user_ids.add(user.id)
all_users.append(user)
# Add chat partners
for user in chat_partners:
if user.id not in all_user_ids:
all_user_ids.add(user.id)
all_users.append(user)
# Remove current user from the list
all_users = [user for user in all_users if user.id != current_user.id]
# Get their statuses
statuses = manager.get_all_user_statuses()
# Build response
result = []
for user in all_users:
status = statuses.get(user.id, "offline")
result.append({
"user_id": user.id,
"username": user.username,
"full_name": user.full_name,
"status": status
})
return result

View File

@ -10,6 +10,12 @@ class SnippetVisibility(str, Enum):
ORGANIZATION = "organization" ORGANIZATION = "organization"
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
SUPERADMIN = "superadmin"
class Language(SQLModel, table=True): class Language(SQLModel, table=True):
__tablename__ = "language" __tablename__ = "language"
@ -64,7 +70,7 @@ class User(SQLModel, table=True):
profile_picture: Optional[str] = None profile_picture: Optional[str] = None
theme: str = Field(default="light") # 'light' or 'dark' theme: str = Field(default="light") # 'light' or 'dark'
is_active: bool = Field(default=True) is_active: bool = Field(default=True)
is_admin: bool = Field(default=False) role: UserRole = Field(default=UserRole.USER)
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships # Relationships
@ -142,6 +148,19 @@ class DirectMessage(SQLModel, table=True):
sender: User = Relationship(back_populates="sent_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.sender_id"}) sender: User = Relationship(back_populates="sent_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.sender_id"})
receiver: User = Relationship(back_populates="received_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.receiver_id"}) receiver: User = Relationship(back_populates="received_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.receiver_id"})
snippet: Optional["Snippet"] = Relationship() snippet: Optional["Snippet"] = Relationship()
reply_to: Optional["DirectMessage"] = Relationship(back_populates="replies", sa_relationship_kwargs={"foreign_keys": "DirectMessage.reply_to_id", "remote_side": "DirectMessage.id"})
replies: List["DirectMessage"] = Relationship(back_populates="reply_to")
attachments: List["DirectMessageAttachment"] = Relationship(back_populates="direct_message")
class LastSeen(SQLModel, table=True):
__tablename__ = "last_seen"
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
channel_id: Optional[int] = Field(default=None)
dm_user_id: Optional[int] = Field(default=None)
last_seen: datetime = Field(default_factory=datetime.utcnow, index=True)
class FileAttachment(SQLModel, table=True): class FileAttachment(SQLModel, table=True):
@ -180,6 +199,27 @@ class FilePermission(SQLModel, table=True):
user: User = Relationship(back_populates="file_permissions") user: User = Relationship(back_populates="file_permissions")
class DirectMessageAttachment(SQLModel, table=True):
__tablename__ = "direct_message_attachment"
id: Optional[int] = Field(default=None, primary_key=True)
filename: str
original_filename: str
mime_type: str
file_size: int
file_path: str
direct_message_id: int = Field(foreign_key="direct_message.id")
uploader_id: Optional[int] = Field(default=None, foreign_key="user.id")
webdav_path: Optional[str] = None
upload_permission: str = Field(default="read") # "read" or "write"
is_editable: bool = Field(default=False)
uploaded_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
direct_message: DirectMessage = Relationship(back_populates="attachments")
uploader: Optional[User] = Relationship()
class Snippet(SQLModel, table=True): class Snippet(SQLModel, table=True):
__tablename__ = "snippet" __tablename__ = "snippet"
@ -215,6 +255,8 @@ class KanbanBoard(SQLModel, table=True):
# Relationships # Relationships
channel: Channel = Relationship(back_populates="kanban_board") channel: Channel = Relationship(back_populates="kanban_board")
columns: List["KanbanColumn"] = Relationship(back_populates="board") columns: List["KanbanColumn"] = Relationship(back_populates="board")
custom_fields: List["KanbanCustomField"] = Relationship(back_populates="board")
templates: List["KanbanCardTemplate"] = Relationship(back_populates="board")
class KanbanColumn(SQLModel, table=True): class KanbanColumn(SQLModel, table=True):
@ -245,6 +287,9 @@ class KanbanCard(SQLModel, table=True):
due_date: Optional[datetime] = Field(default=None) due_date: Optional[datetime] = Field(default=None)
priority: Optional[str] = Field(default="medium") # low, medium, high priority: Optional[str] = Field(default="medium") # low, medium, high
labels: Optional[str] = Field(default=None) # JSON string for labels/tags labels: Optional[str] = Field(default=None) # JSON string for labels/tags
estimated_time: Optional[int] = Field(default=None) # Estimated time in minutes
actual_time: Optional[int] = Field(default=None) # Actual time spent in minutes
is_archived: bool = Field(default=False) # Soft delete - card is archived but not deleted
created_at: datetime = Field(default_factory=datetime.utcnow) created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow)
@ -252,6 +297,10 @@ class KanbanCard(SQLModel, table=True):
column: KanbanColumn = Relationship(back_populates="cards") column: KanbanColumn = Relationship(back_populates="cards")
assignee: Optional[User] = Relationship() assignee: Optional[User] = Relationship()
checklists: List["KanbanChecklist"] = Relationship(back_populates="card") checklists: List["KanbanChecklist"] = Relationship(back_populates="card")
comments: List["KanbanCardComment"] = Relationship(back_populates="card")
attachments: List["KanbanCardAttachment"] = Relationship(back_populates="card")
time_entries: List["KanbanTimeEntry"] = Relationship(back_populates="card")
custom_field_values: List["KanbanCustomFieldValue"] = Relationship(back_populates="card")
class KanbanChecklist(SQLModel, table=True): class KanbanChecklist(SQLModel, table=True):
@ -282,3 +331,124 @@ class KanbanChecklistItem(SQLModel, table=True):
# Relationships # Relationships
checklist: KanbanChecklist = Relationship(back_populates="items") checklist: KanbanChecklist = Relationship(back_populates="items")
# Kanban Card Comments
class KanbanCardComment(SQLModel, table=True):
__tablename__ = "kanban_card_comment"
id: Optional[int] = Field(default=None, primary_key=True)
card_id: int = Field(foreign_key="kanban_card.id")
user_id: int = Field(foreign_key="user.id")
content: str
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
card: KanbanCard = Relationship(back_populates="comments")
user: User = Relationship()
# Kanban Card Attachments
class KanbanCardAttachment(SQLModel, table=True):
__tablename__ = "kanban_card_attachment"
id: Optional[int] = Field(default=None, primary_key=True)
card_id: int = Field(foreign_key="kanban_card.id")
filename: str
original_filename: str
mime_type: str
file_size: int
file_path: str
uploader_id: int = Field(foreign_key="user.id")
uploaded_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
card: KanbanCard = Relationship(back_populates="attachments")
uploader: User = Relationship()
# Kanban Time Tracking
class KanbanTimeEntry(SQLModel, table=True):
__tablename__ = "kanban_time_entry"
id: Optional[int] = Field(default=None, primary_key=True)
card_id: int = Field(foreign_key="kanban_card.id")
user_id: int = Field(foreign_key="user.id")
description: Optional[str] = Field(default=None)
start_time: datetime
end_time: Optional[datetime] = Field(default=None)
duration_minutes: Optional[int] = Field(default=None) # Calculated field
is_running: bool = Field(default=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
card: KanbanCard = Relationship(back_populates="time_entries")
user: User = Relationship()
# Kanban Custom Fields
class KanbanCustomField(SQLModel, table=True):
__tablename__ = "kanban_custom_field"
id: Optional[int] = Field(default=None, primary_key=True)
board_id: int = Field(foreign_key="kanban_board.id")
name: str
field_type: str # 'text', 'number', 'date', 'select', 'multiselect', 'checkbox'
options: Optional[str] = Field(default=None) # JSON string for select options
is_required: bool = Field(default=False)
position: int = Field(default=0)
created_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
board: KanbanBoard = Relationship(back_populates="custom_fields")
values: List["KanbanCustomFieldValue"] = Relationship(back_populates="field")
class KanbanCustomFieldValue(SQLModel, table=True):
__tablename__ = "kanban_custom_field_value"
id: Optional[int] = Field(default=None, primary_key=True)
field_id: int = Field(foreign_key="kanban_custom_field.id")
card_id: int = Field(foreign_key="kanban_card.id")
value: str # JSON string for the value
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
field: KanbanCustomField = Relationship(back_populates="values")
card: KanbanCard = Relationship(back_populates="custom_field_values")
# Kanban Card Activity Log
class KanbanCardActivityLog(SQLModel, table=True):
__tablename__ = "kanban_card_activity_log"
id: Optional[int] = Field(default=None, primary_key=True)
card_id: int = Field(foreign_key="kanban_card.id")
user_id: int = Field(foreign_key="user.id")
action: str # 'created', 'moved', 'updated', 'commented', etc.
field_name: Optional[str] = Field(default=None) # Which field was changed
old_value: Optional[str] = Field(default=None) # Old value as string
new_value: Optional[str] = Field(default=None) # New value as string
created_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
card: KanbanCard = Relationship()
user: User = Relationship()
# Kanban Card Templates
class KanbanCardTemplate(SQLModel, table=True):
__tablename__ = "kanban_card_template"
id: Optional[int] = Field(default=None, primary_key=True)
board_id: int = Field(foreign_key="kanban_board.id")
name: str
description: Optional[str] = Field(default=None)
template_data: str # JSON string containing template data
is_default: bool = Field(default=False)
created_at: datetime = Field(default_factory=datetime.utcnow)
# Relationships
board: KanbanBoard = Relationship(back_populates="templates")

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
from app.database import get_session from app.database import get_session
from app.models import User, Department, UserDepartmentLink, Channel, Snippet, SnippetDepartmentLink, Language, Translation from app.models import User, Department, UserDepartmentLink, Channel, Snippet, SnippetDepartmentLink, Language, Translation, UserRole
from app.schemas import ( from app.schemas import (
DepartmentCreate, DepartmentResponse, DepartmentCreate, DepartmentResponse,
ChannelCreate, ChannelResponse, ChannelCreate, ChannelResponse,
@ -32,7 +32,7 @@ class UserDepartmentAssignment(BaseModel):
class UserAdminUpdate(BaseModel): class UserAdminUpdate(BaseModel):
user_id: int user_id: int
is_admin: bool role: UserRole
class SnippetDepartmentAccess(BaseModel): class SnippetDepartmentAccess(BaseModel):
@ -43,8 +43,8 @@ class SnippetDepartmentAccess(BaseModel):
def require_admin(current_user: User = Depends(get_current_user)) -> User: def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Verify that the current user is an admin""" """Verify that the current user is an admin or superadmin"""
if not current_user.is_admin: if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required" detail="Admin privileges required"
@ -52,12 +52,22 @@ def require_admin(current_user: User = Depends(get_current_user)) -> User:
return current_user return current_user
def require_superadmin(current_user: User = Depends(get_current_user)) -> User:
"""Verify that the current user is a superadmin"""
if current_user.role != UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Superadmin privileges required"
)
return current_user
# ========== User Management ========== # ========== User Management ==========
@router.get("/users", response_model=List[UserResponse]) @router.get("/users", response_model=List[UserResponse])
def get_all_users( def get_all_users(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Get all users (Admin only)""" """Get all users (Admin only)"""
statement = select(User) statement = select(User)
@ -65,14 +75,14 @@ def get_all_users(
return users return users
@router.patch("/users/{user_id}/admin") @router.patch("/users/{user_id}/role")
def toggle_admin_status( def update_user_role(
user_id: int, user_id: int,
is_admin: bool, body: UserAdminUpdate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Make a user admin or remove admin privileges""" """Update a user's role (Superadmin only)"""
user = session.get(User, user_id) user = session.get(User, user_id)
if not user: if not user:
raise HTTPException( raise HTTPException(
@ -80,12 +90,19 @@ def toggle_admin_status(
detail="User not found" detail="User not found"
) )
user.is_admin = is_admin # Prevent superadmin from demoting themselves
if admin.id == user_id and body.role != UserRole.SUPERADMIN:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot change your own superadmin role"
)
user.role = body.role
session.add(user) session.add(user)
session.commit() session.commit()
session.refresh(user) session.refresh(user)
return {"message": f"User {user.username} admin status updated", "is_admin": is_admin} return {"message": f"User {user.username} role updated to {body.role.value}", "role": body.role}
# ========== Department Management ========== # ========== Department Management ==========
@ -94,7 +111,7 @@ def toggle_admin_status(
def create_department( def create_department(
department_data: DepartmentCreate, department_data: DepartmentCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Create a new department (Admin only)""" """Create a new department (Admin only)"""
department = Department(**department_data.model_dump()) department = Department(**department_data.model_dump())
@ -107,7 +124,7 @@ def create_department(
@router.get("/departments", response_model=List[DepartmentResponse]) @router.get("/departments", response_model=List[DepartmentResponse])
def get_all_departments( def get_all_departments(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Get all departments (Admin only)""" """Get all departments (Admin only)"""
statement = select(Department) statement = select(Department)
@ -120,7 +137,7 @@ def update_department(
department_id: int, department_id: int,
department_data: DepartmentCreate, department_data: DepartmentCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Update a department (Admin only)""" """Update a department (Admin only)"""
department = session.get(Department, department_id) department = session.get(Department, department_id)
@ -145,7 +162,7 @@ def toggle_department_snippets(
department_id: int, department_id: int,
enabled: bool, enabled: bool,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Enable or disable snippet access for entire department (master switch)""" """Enable or disable snippet access for entire department (master switch)"""
department = session.get(Department, department_id) department = session.get(Department, department_id)
@ -171,7 +188,7 @@ def toggle_department_snippets(
def delete_department( def delete_department(
department_id: int, department_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Delete a department (Admin only)""" """Delete a department (Admin only)"""
department = session.get(Department, department_id) department = session.get(Department, department_id)
@ -209,7 +226,7 @@ def assign_user_to_department(
department_id: int, department_id: int,
user_id: int, user_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Assign a user to a department (Admin only)""" """Assign a user to a department (Admin only)"""
# Check if department exists # Check if department exists
@ -254,7 +271,7 @@ def remove_user_from_department(
department_id: int, department_id: int,
user_id: int, user_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Remove a user from a department (Admin only)""" """Remove a user from a department (Admin only)"""
statement = select(UserDepartmentLink).where( statement = select(UserDepartmentLink).where(
@ -279,7 +296,7 @@ def remove_user_from_department(
def get_department_members( def get_department_members(
department_id: int, department_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Get all members of a department (Admin only)""" """Get all members of a department (Admin only)"""
department = session.get(Department, department_id) department = session.get(Department, department_id)
@ -327,6 +344,38 @@ def create_channel(
return channel return channel
@router.put("/channels/{channel_id}", response_model=ChannelResponse)
def update_channel(
channel_id: int,
channel_data: ChannelCreate,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
):
"""Update a channel (Admin only)"""
channel = session.get(Channel, channel_id)
if not channel:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Channel not found"
)
# Verify department exists
department = session.get(Department, channel_data.department_id)
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Department not found"
)
channel.name = channel_data.name
channel.description = channel_data.description
channel.department_id = channel_data.department_id
session.add(channel)
session.commit()
session.refresh(channel)
return channel
@router.delete("/channels/{channel_id}") @router.delete("/channels/{channel_id}")
def delete_channel( def delete_channel(
channel_id: int, channel_id: int,
@ -352,7 +401,7 @@ def delete_channel(
@router.get("/languages", response_model=List[LanguageResponse]) @router.get("/languages", response_model=List[LanguageResponse])
def get_languages( def get_languages(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""List all available UI languages.""" """List all available UI languages."""
ensure_default_languages(session) ensure_default_languages(session)
@ -364,7 +413,7 @@ def get_languages(
def create_language( def create_language(
language_data: LanguageCreate, language_data: LanguageCreate,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Create a new UI language.""" """Create a new UI language."""
code = language_data.code.strip().lower() code = language_data.code.strip().lower()
@ -408,7 +457,7 @@ def create_language(
def delete_language( def delete_language(
language_id: int, language_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Remove a UI language.""" """Remove a UI language."""
language = session.get(Language, language_id) language = session.get(Language, language_id)
@ -438,7 +487,7 @@ def delete_language(
@router.get("/translations", response_model=List[TranslationGroupResponse]) @router.get("/translations", response_model=List[TranslationGroupResponse])
def get_translations( def get_translations(
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Retrieve translation values grouped by attribute.""" """Retrieve translation values grouped by attribute."""
ensure_default_languages(session) ensure_default_languages(session)
@ -498,7 +547,7 @@ def get_translations(
def update_translation( def update_translation(
payload: TranslationUpdateRequest, payload: TranslationUpdateRequest,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Update a single translation entry.""" """Update a single translation entry."""
translation = session.get(Translation, payload.translation_id) translation = session.get(Translation, payload.translation_id)
@ -527,7 +576,7 @@ def update_translation(
def get_snippet_departments( def get_snippet_departments(
snippet_id: int, snippet_id: int,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Get all departments and their access status for a snippet. """Get all departments and their access status for a snippet.
By default, snippets are disabled for all departments. By default, snippets are disabled for all departments.
@ -563,7 +612,7 @@ def get_snippet_departments(
def toggle_snippet_department_access( def toggle_snippet_department_access(
access_data: SnippetDepartmentAccess, access_data: SnippetDepartmentAccess,
session: Session = Depends(get_session), session: Session = Depends(get_session),
admin: User = Depends(require_admin) admin: User = Depends(require_superadmin)
): ):
"""Enable or disable a snippet for a specific department. """Enable or disable a snippet for a specific department.
By default, all snippets are disabled for all departments. By default, all snippets are disabled for all departments.

View File

@ -2,7 +2,7 @@ from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select from sqlmodel import Session, select
from typing import List from typing import List
from app.database import get_session from app.database import get_session
from app.models import Channel, Department, User, KanbanBoard, KanbanColumn from app.models import Channel, Department, User, KanbanBoard, KanbanColumn, UserRole
from app.schemas import ChannelCreate, ChannelResponse from app.schemas import ChannelCreate, ChannelResponse
from app.auth import get_current_user from app.auth import get_current_user
@ -107,7 +107,7 @@ def get_channel(
user = session.exec(statement).first() user = session.exec(statement).first()
user_dept_ids = [dept.id for dept in user.departments] if user else [] user_dept_ids = [dept.id for dept in user.departments] if user else []
if channel.department_id not in user_dept_ids: if channel.department_id not in user_dept_ids and current_user.role != UserRole.SUPERADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this channel" detail="You don't have access to this channel"
@ -128,7 +128,7 @@ def get_channels_by_department(
user = session.exec(statement).first() user = session.exec(statement).first()
user_dept_ids = [dept.id for dept in user.departments] if user else [] user_dept_ids = [dept.id for dept in user.departments] if user else []
if department_id not in user_dept_ids: if department_id not in user_dept_ids and current_user.role != UserRole.SUPERADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this department" detail="You don't have access to this department"

View File

@ -3,7 +3,7 @@ from sqlmodel import Session, select
from typing import List from typing import List
from app.database import get_session from app.database import get_session
from app.models import Department, User from app.models import Department, User
from app.schemas import DepartmentCreate, DepartmentResponse from app.schemas import DepartmentCreate, DepartmentResponse, UserResponse
from app.auth import get_current_user from app.auth import get_current_user
router = APIRouter(prefix="/departments", tags=["Departments"]) router = APIRouter(prefix="/departments", tags=["Departments"])
@ -59,6 +59,32 @@ def get_my_departments(
return user.departments if user else [] return user.departments if user else []
@router.get("/{department_id}/users", response_model=List[UserResponse])
def get_department_users(
department_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Get all users in a department"""
# Check if department exists
department = session.get(Department, department_id)
if not department:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Department not found"
)
# Check if current user has access to this department
user_departments = [dept.id for dept in current_user.departments]
if department_id not in user_departments and current_user.role not in ["admin", "superadmin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Access denied"
)
return department.users
@router.post("/{department_id}/users/{user_id}") @router.post("/{department_id}/users/{user_id}")
def add_user_to_department( def add_user_to_department(
department_id: int, department_id: int,

View File

@ -1,11 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status, File, UploadFile, Form
from sqlmodel import Session, select, or_, and_ from sqlmodel import Session, select, or_, and_
from typing import List from sqlalchemy.orm import joinedload
from typing import List, Optional
from app.database import get_session from app.database import get_session
from app.models import DirectMessage, User from app.models import DirectMessage, User, Snippet, DirectMessageAttachment
from app.schemas import DirectMessageCreate, DirectMessageResponse from app.schemas import DirectMessageCreate, DirectMessageResponse, DirectMessageAttachmentResponse
from app.auth import get_current_user from app.auth import get_current_user
from app.websocket import manager from app.websocket import manager
import os
import uuid
from pathlib import Path
router = APIRouter(prefix="/direct-messages", tags=["Direct Messages"]) router = APIRouter(prefix="/direct-messages", tags=["Direct Messages"])
@ -36,19 +40,82 @@ async def create_direct_message(
content=message_data.content, content=message_data.content,
sender_id=current_user.id, sender_id=current_user.id,
receiver_id=message_data.receiver_id, receiver_id=message_data.receiver_id,
snippet_id=message_data.snippet_id snippet_id=message_data.snippet_id,
reply_to_id=message_data.reply_to_id
) )
session.add(new_message) session.add(new_message)
session.commit() session.commit()
session.refresh(new_message) session.refresh(new_message)
# Build response # Load snippet data if present
response = DirectMessageResponse.model_validate(new_message) snippet_data = None
response.sender_username = current_user.username if new_message.snippet_id:
response.receiver_username = receiver.username snippet = session.get(Snippet, new_message.snippet_id)
response.sender_full_name = current_user.full_name if snippet:
response.sender_profile_picture = current_user.profile_picture snippet_owner = session.get(User, snippet.owner_id)
snippet_data = {
"id": snippet.id,
"title": snippet.title,
"content": snippet.content,
"language": snippet.language,
"tags": snippet.tags,
"visibility": snippet.visibility,
"department_id": snippet.department_id,
"owner_id": snippet.owner_id,
"owner_username": snippet_owner.username if snippet_owner else "Unknown",
"created_at": snippet.created_at,
"updated_at": snippet.updated_at
}
# Load reply_to data if present
reply_to_data = None
if new_message.reply_to_id:
reply_to_msg = session.get(DirectMessage, new_message.reply_to_id)
if reply_to_msg:
reply_sender = session.get(User, reply_to_msg.sender_id)
reply_to_data = {
"id": reply_to_msg.id,
"content": reply_to_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
}
# Build attachment data
attachments_data = [
{
"id": att.id,
"filename": att.filename,
"original_filename": att.original_filename,
"mime_type": att.mime_type,
"file_size": att.file_size,
"file_path": att.file_path,
"direct_message_id": att.direct_message_id,
"uploaded_at": att.uploaded_at,
"upload_permission": att.upload_permission,
"uploader_id": att.uploader_id,
"is_editable": att.is_editable
}
for att in new_message.attachments
]
# Build response using constructor
response = DirectMessageResponse(
id=new_message.id,
content=new_message.content,
sender_id=new_message.sender_id,
receiver_id=new_message.receiver_id,
snippet_id=new_message.snippet_id,
created_at=new_message.created_at,
is_read=new_message.is_read,
sender_username=current_user.username,
receiver_username=receiver.username,
sender_full_name=current_user.full_name,
sender_profile_picture=current_user.profile_picture,
snippet=snippet_data,
reply_to=reply_to_data,
attachments=attachments_data
)
# Broadcast via WebSocket to receiver (using negative user ID as "channel") # Broadcast via WebSocket to receiver (using negative user ID as "channel")
response_data = { response_data = {
@ -63,7 +130,10 @@ async def create_direct_message(
"created_at": new_message.created_at.isoformat(), "created_at": new_message.created_at.isoformat(),
"is_read": new_message.is_read, "is_read": new_message.is_read,
"snippet_id": new_message.snippet_id, "snippet_id": new_message.snippet_id,
"snippet": None "snippet": snippet_data,
"reply_to_id": new_message.reply_to_id,
"reply_to": reply_to_data,
"attachments": attachments_data
} }
# Broadcast to both sender and receiver using their user IDs as "channel" # Broadcast to both sender and receiver using their user IDs as "channel"
@ -76,9 +146,52 @@ async def create_direct_message(
-current_user.id -current_user.id
) )
# Update user activity
manager.update_activity(current_user.id)
return response return response
@router.delete("/{message_id}", status_code=status.HTTP_200_OK)
async def delete_direct_message(
message_id: int,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Delete a direct message (only sender can delete)"""
message = session.get(DirectMessage, message_id)
if not message:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Message not found"
)
# Only sender can delete
if message.sender_id != current_user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You can only delete your own messages"
)
# Delete associated attachments and files
attachments = session.query(DirectMessageAttachment).filter(
DirectMessageAttachment.direct_message_id == message_id
).all()
for attachment in attachments:
# Delete file from disk
file_path = Path(attachment.file_path)
if file_path.exists():
try:
file_path.unlink()
except Exception as e:
print(f"Failed to delete file: {e}")
session.delete(attachment)
session.delete(message)
session.commit()
return {"success": True, "message": "Message deleted"}
@router.get("/conversation/{user_id}", response_model=List[DirectMessageResponse]) @router.get("/conversation/{user_id}", response_model=List[DirectMessageResponse])
def get_conversation( def get_conversation(
user_id: int, user_id: int,
@ -105,6 +218,7 @@ def get_conversation(
and_(DirectMessage.sender_id == user_id, DirectMessage.receiver_id == current_user.id) and_(DirectMessage.sender_id == user_id, DirectMessage.receiver_id == current_user.id)
) )
) )
.options(joinedload(DirectMessage.snippet))
.order_by(DirectMessage.created_at.desc()) .order_by(DirectMessage.created_at.desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@ -121,13 +235,76 @@ def get_conversation(
# Build responses # Build responses
responses = [] responses = []
for msg in messages: for msg in messages:
msg_response = DirectMessageResponse.model_validate(msg)
sender = session.get(User, msg.sender_id) sender = session.get(User, msg.sender_id)
receiver = session.get(User, msg.receiver_id) receiver = session.get(User, msg.receiver_id)
msg_response.sender_username = sender.username if sender else "Unknown"
msg_response.receiver_username = receiver.username if receiver else "Unknown" # Build reply_to data if it exists
msg_response.sender_full_name = sender.full_name if sender else None reply_to_data = None
msg_response.sender_profile_picture = sender.profile_picture if sender else None if msg.reply_to_id:
reply_to_msg = session.get(DirectMessage, msg.reply_to_id)
if reply_to_msg:
reply_sender = session.get(User, reply_to_msg.sender_id)
reply_to_data = {
"id": reply_to_msg.id,
"content": reply_to_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
}
# Build attachment data
attachments_data = [
{
"id": att.id,
"filename": att.filename,
"original_filename": att.original_filename,
"mime_type": att.mime_type,
"file_size": att.file_size,
"file_path": att.file_path,
"direct_message_id": att.direct_message_id,
"uploaded_at": att.uploaded_at,
"upload_permission": att.upload_permission,
"uploader_id": att.uploader_id,
"is_editable": att.is_editable
}
for att in msg.attachments
]
# Build snippet data
snippet_data = None
if msg.snippet:
snippet_owner = session.get(User, msg.snippet.owner_id)
snippet_data = {
"id": msg.snippet.id,
"title": msg.snippet.title,
"content": msg.snippet.content,
"language": msg.snippet.language,
"tags": msg.snippet.tags,
"visibility": msg.snippet.visibility,
"department_id": msg.snippet.department_id,
"owner_id": msg.snippet.owner_id,
"owner_username": snippet_owner.username if snippet_owner else "Unknown",
"created_at": msg.snippet.created_at,
"updated_at": msg.snippet.updated_at
}
# Create response using dict constructor
msg_response = DirectMessageResponse(
id=msg.id,
content=msg.content,
sender_id=msg.sender_id,
receiver_id=msg.receiver_id,
snippet_id=msg.snippet_id,
created_at=msg.created_at,
is_read=msg.is_read,
sender_username=sender.username if sender else "Unknown",
receiver_username=receiver.username if receiver else "Unknown",
sender_full_name=sender.full_name if sender else None,
sender_profile_picture=sender.profile_picture if sender else None,
snippet=snippet_data,
reply_to=reply_to_data,
attachments=attachments_data
)
responses.append(msg_response) responses.append(msg_response)
# Reverse to show oldest first # Reverse to show oldest first
@ -186,3 +363,149 @@ def get_conversations(
}) })
return conversations return conversations
@router.post("/{user_id}/upload", status_code=status.HTTP_201_CREATED)
async def upload_direct_message_file(
user_id: int,
file: UploadFile = File(...),
content: str = Form(default=""),
permission: str = Form(default="read"),
reply_to_id: str = Form(default=""),
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user)
):
"""Upload a file with a direct message"""
# Convert reply_to_id from string to int or None
parsed_reply_to_id: Optional[int] = None
if reply_to_id and reply_to_id.strip():
try:
parsed_reply_to_id = int(reply_to_id)
except ValueError:
parsed_reply_to_id = None
# Check if receiver exists
receiver = session.get(User, user_id)
if not receiver:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Receiver not found"
)
# Can't send message to yourself
if user_id == current_user.id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot send message to yourself"
)
# Create uploads directory if it doesn't exist
upload_dir = Path(__file__).parent.parent.parent / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
# Generate unique filename
file_ext = Path(file.filename).suffix
unique_filename = f"{uuid.uuid4()}{file_ext}"
file_path = upload_dir / unique_filename
# Save file to disk
file_content = await file.read()
with open(file_path, 'wb') as f:
f.write(file_content)
# Create direct message
new_message = DirectMessage(
content=content or f"Shared file: {file.filename}",
sender_id=current_user.id,
receiver_id=user_id,
reply_to_id=parsed_reply_to_id
)
session.add(new_message)
session.commit()
session.refresh(new_message)
# Create file attachment
attachment = DirectMessageAttachment(
filename=unique_filename,
original_filename=file.filename,
mime_type=file.content_type or "application/octet-stream",
file_size=len(file_content),
file_path=str(file_path),
direct_message_id=new_message.id,
uploader_id=current_user.id,
upload_permission=permission
)
session.add(attachment)
session.commit()
session.refresh(attachment)
# Build response
response = DirectMessageResponse.model_validate(new_message)
response.sender_username = current_user.username
response.receiver_username = receiver.username
response.sender_full_name = current_user.full_name
response.sender_profile_picture = current_user.profile_picture
# Add attachment to response
att_response = DirectMessageAttachmentResponse.model_validate(attachment)
response.attachments = [att_response]
# Load reply_to data if present
reply_to_data = None
if new_message.reply_to_id:
reply_to_msg = session.get(DirectMessage, new_message.reply_to_id)
if reply_to_msg:
reply_sender = session.get(User, reply_to_msg.sender_id)
reply_to_data = {
"id": reply_to_msg.id,
"content": reply_to_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
}
# Broadcast via WebSocket
response_data = {
"id": new_message.id,
"content": new_message.content,
"sender_id": new_message.sender_id,
"receiver_id": new_message.receiver_id,
"sender_username": current_user.username,
"receiver_username": receiver.username,
"sender_full_name": current_user.full_name,
"sender_profile_picture": current_user.profile_picture,
"created_at": new_message.created_at.isoformat(),
"is_read": new_message.is_read,
"snippet_id": new_message.snippet_id,
"snippet": None,
"reply_to_id": new_message.reply_to_id,
"reply_to": reply_to_data,
"attachments": [{
"id": attachment.id,
"filename": attachment.filename,
"original_filename": attachment.original_filename,
"mime_type": attachment.mime_type,
"file_size": attachment.file_size,
"uploaded_at": attachment.uploaded_at.isoformat(),
"direct_message_id": attachment.direct_message_id,
"upload_permission": attachment.upload_permission,
"uploader_id": attachment.uploader_id,
"is_editable": attachment.is_editable
}]
}
# Broadcast to both sender and receiver
await manager.broadcast_to_channel(
{"type": "direct_message", "message": response_data},
-user_id
)
await manager.broadcast_to_channel(
{"type": "direct_message", "message": response_data},
-current_user.id
)
# Update user activity
manager.update_activity(current_user.id)
return response

View File

@ -7,7 +7,7 @@ import uuid
import aiofiles import aiofiles
from urllib.parse import quote from urllib.parse import quote
from app.database import get_session from app.database import get_session
from app.models import FileAttachment, Message, User, Channel from app.models import FileAttachment, Message, User, Channel, UserRole
from app.schemas import FileAttachmentResponse, MessageResponse from app.schemas import FileAttachmentResponse, MessageResponse
from app.auth import get_current_user from app.auth import get_current_user
from app.config import get_settings from app.config import get_settings
@ -266,7 +266,8 @@ async def upload_file_with_message(
reply_to_data = { reply_to_data = {
"id": reply_msg.id, "id": reply_msg.id,
"content": reply_msg.content, "content": reply_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown" "sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
} }
attachment_data = { attachment_data = {
@ -409,7 +410,7 @@ async def update_file_permission(
) )
# Check if user is the uploader or an admin # Check if user is the uploader or an admin
if file_attachment.uploader_id != current_user.id and not current_user.is_admin: if file_attachment.uploader_id != current_user.id and current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Only the uploader or an admin can change file permissions" detail="Only the uploader or an admin can change file permissions"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,103 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlmodel import Session, select
from datetime import datetime
from typing import Optional
from app.database import get_session
from app.auth import get_current_user
from app.models import User, Channel, LastSeen
from app.websocket import manager
router = APIRouter(prefix="/me/last-seen", tags=["LastSeen"])
@router.post("/")
async def set_last_seen(
channel_id: Optional[int] = None,
dm_user_id: Optional[int] = None,
last_seen: Optional[str] = None,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
if not channel_id and not dm_user_id:
raise HTTPException(status_code=400, detail="channel_id or dm_user_id required")
# Validate target exists
if channel_id:
channel = session.get(Channel, channel_id)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
# Basic access check: user must belong to the channel's department
user_dept_ids = [d.id for d in current_user.departments] if current_user.departments else []
if channel.department_id not in user_dept_ids:
raise HTTPException(status_code=403, detail="No access to channel")
if dm_user_id:
other = session.get(User, dm_user_id)
if not other:
raise HTTPException(status_code=404, detail="Target user not found")
ts = datetime.utcnow() if not last_seen else datetime.fromisoformat(last_seen)
# Upsert last seen
if channel_id:
statement = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.channel_id == channel_id)
else:
statement = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.dm_user_id == dm_user_id)
exists = session.exec(statement).first()
if exists:
exists.last_seen = ts
session.add(exists)
session.commit()
session.refresh(exists)
result = exists
else:
payload = LastSeen(user_id=current_user.id, channel_id=channel_id, dm_user_id=dm_user_id, last_seen=ts)
session.add(payload)
session.commit()
session.refresh(payload)
result = payload
# Broadcast read_marker to relevant channel or DM partner
try:
message = {
"type": "read_marker",
"user_id": current_user.id,
"last_seen": result.last_seen.isoformat(),
}
if channel_id:
message["channel_id"] = channel_id
await manager.broadcast_to_channel(message, channel_id)
elif dm_user_id:
message["dm_user_id"] = dm_user_id
# partner listens on channel id = -their_user_id
await manager.broadcast_to_channel(message, -dm_user_id)
# also broadcast to presence channel 0
await manager.broadcast_to_channel({**message, "type": "read_marker"}, 0)
except Exception:
pass
return {"id": result.id, "user_id": result.user_id, "channel_id": result.channel_id, "dm_user_id": result.dm_user_id, "last_seen": result.last_seen.isoformat()}
@router.get("/")
def get_last_seen(
channel_id: Optional[int] = None,
dm_user_id: Optional[int] = None,
session: Session = Depends(get_session),
current_user: User = Depends(get_current_user),
):
if not channel_id and not dm_user_id:
raise HTTPException(status_code=400, detail="channel_id or dm_user_id required")
if channel_id:
stmt = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.channel_id == channel_id)
else:
stmt = select(LastSeen).where(LastSeen.user_id == current_user.id, LastSeen.dm_user_id == dm_user_id)
found = session.exec(stmt).first()
if not found:
raise HTTPException(status_code=404, detail="LastSeen not found")
return {"id": found.id, "user_id": found.user_id, "channel_id": found.channel_id, "dm_user_id": found.dm_user_id, "last_seen": found.last_seen.isoformat()}

View File

@ -4,7 +4,7 @@ from sqlalchemy.orm import joinedload
from typing import List from typing import List
import os import os
from app.database import get_session from app.database import get_session
from app.models import Message, Channel, User, FileAttachment from app.models import Message, Channel, User, FileAttachment, Snippet
from app.schemas import MessageCreate, MessageResponse from app.schemas import MessageCreate, MessageResponse
from app.auth import get_current_user from app.auth import get_current_user
from app.websocket import manager from app.websocket import manager
@ -66,7 +66,22 @@ async def create_message(
reply_to_data = { reply_to_data = {
"id": reply_msg.id, "id": reply_msg.id,
"content": reply_msg.content, "content": reply_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown" "sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
}
# Load snippet data if present
snippet_data = None
if new_message.snippet_id:
snippet = session.get(Snippet, new_message.snippet_id)
if snippet:
snippet_data = {
"id": snippet.id,
"title": snippet.title,
"content": snippet.content,
"language": snippet.language,
"created_at": snippet.created_at.isoformat(),
"updated_at": snippet.updated_at.isoformat()
} }
response_data = { response_data = {
@ -81,7 +96,7 @@ async def create_message(
"snippet_id": new_message.snippet_id, "snippet_id": new_message.snippet_id,
"reply_to_id": new_message.reply_to_id, "reply_to_id": new_message.reply_to_id,
"reply_to": reply_to_data, "reply_to": reply_to_data,
"snippet": None, "snippet": snippet_data,
"attachments": [], "attachments": [],
"is_deleted": False "is_deleted": False
} }
@ -95,6 +110,9 @@ async def create_message(
message_data.channel_id message_data.channel_id
) )
# Update user activity
manager.update_activity(current_user.id)
# Return proper response # Return proper response
response = MessageResponse.model_validate(new_message) response = MessageResponse.model_validate(new_message)
response.sender_username = current_user.username response.sender_username = current_user.username
@ -124,6 +142,7 @@ def get_channel_messages(
select(Message) select(Message)
.where(Message.channel_id == channel_id) .where(Message.channel_id == channel_id)
.options(joinedload(Message.attachments)) .options(joinedload(Message.attachments))
.options(joinedload(Message.snippet))
.order_by(Message.created_at.desc()) .order_by(Message.created_at.desc())
.offset(offset) .offset(offset)
.limit(limit) .limit(limit)
@ -147,7 +166,8 @@ def get_channel_messages(
msg_response.reply_to = { msg_response.reply_to = {
"id": reply_msg.id, "id": reply_msg.id,
"content": reply_msg.content, "content": reply_msg.content,
"sender_username": reply_sender.username if reply_sender else "Unknown" "sender_username": reply_sender.username if reply_sender else "Unknown",
"sender_full_name": reply_sender.full_name if reply_sender else None
} }
responses.append(msg_response) responses.append(msg_response)

View File

@ -3,7 +3,7 @@ from sqlmodel import Session, select, or_, and_
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
from app.database import get_session from app.database import get_session
from app.models import Snippet, User, Department, SnippetVisibility, SnippetDepartmentLink from app.models import Snippet, User, Department, SnippetVisibility, SnippetDepartmentLink, UserRole
from app.schemas import ( from app.schemas import (
SnippetCreate, SnippetCreate,
SnippetUpdate, SnippetUpdate,
@ -83,7 +83,7 @@ def create_snippet(
# Check if user belongs to that department # Check if user belongs to that department
user_dept_ids = [dept.id for dept in current_user.departments] user_dept_ids = [dept.id for dept in current_user.departments]
if snippet_data.department_id not in user_dept_ids: if snippet_data.department_id not in user_dept_ids and current_user.role != UserRole.SUPERADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="You don't belong to this department" detail="You don't belong to this department"

View File

@ -2,12 +2,12 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query
from sqlmodel import Session from sqlmodel import Session
from app.database import get_session from app.database import get_session
from app.websocket import manager from app.websocket import manager
from app.auth import decode_access_token from app.auth import decode_access_token, get_current_user
from app.models import User, Channel from app.models import User, Channel, UserRole
from sqlmodel import select from sqlmodel import select
import json import json
router = APIRouter() router = APIRouter(tags=["WebSocket"])
@router.websocket("/ws/{channel_id}") @router.websocket("/ws/{channel_id}")
@ -34,12 +34,13 @@ async def websocket_endpoint(
return return
# Negative channel_id means direct messages (user_id) # Negative channel_id means direct messages (user_id)
# channel_id 0 means presence-only connection
if channel_id < 0: if channel_id < 0:
# Direct message connection - verify it's the user's own connection # Direct message connection - verify it's the user's own connection
if -channel_id != user.id: if -channel_id != user.id:
await websocket.close(code=1008, reason="Access denied") await websocket.close(code=1008, reason="Access denied")
return return
else: elif channel_id > 0:
# Regular channel - verify channel exists and user has access # Regular channel - verify channel exists and user has access
channel = session.get(Channel, channel_id) channel = session.get(Channel, channel_id)
if not channel: if not channel:
@ -47,12 +48,16 @@ async def websocket_endpoint(
return return
user_dept_ids = [dept.id for dept in user.departments] user_dept_ids = [dept.id for dept in user.departments]
if channel.department_id not in user_dept_ids: if channel.department_id not in user_dept_ids and user.role != UserRole.SUPERADMIN:
await websocket.close(code=1008, reason="Access denied") await websocket.close(code=1008, reason="Access denied")
return return
# channel_id 0 is allowed for presence-only connections
# Connect to channel # Connect to channel
await manager.connect(websocket, channel_id) await manager.connect(websocket, channel_id, user.id)
# Broadcast user status update (online)
await manager.broadcast_user_status_update(user.id, "online")
try: try:
# Send welcome message # Send welcome message
@ -89,7 +94,11 @@ async def websocket_endpoint(
) )
except WebSocketDisconnect: except WebSocketDisconnect:
manager.disconnect(websocket, channel_id) manager.disconnect(websocket, channel_id, user.id)
# Broadcast user status update (offline)
await manager.broadcast_user_status_update(user.id, "offline")
except Exception as e: except Exception as e:
manager.disconnect(websocket, channel_id) manager.disconnect(websocket, channel_id, user.id)
# Broadcast user status update (offline)
await manager.broadcast_user_status_update(user.id, "offline")
print(f"WebSocket error: {e}") print(f"WebSocket error: {e}")

View File

@ -1,6 +1,13 @@
from pydantic import BaseModel, EmailStr from pydantic import BaseModel, EmailStr
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime
from enum import Enum
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
SUPERADMIN = "superadmin"
# User Schemas # User Schemas
@ -31,7 +38,7 @@ class UserLogin(BaseModel):
class UserResponse(UserBase): class UserResponse(UserBase):
id: int id: int
is_active: bool is_active: bool
is_admin: bool = False role: UserRole = UserRole.USER
created_at: datetime created_at: datetime
class Config: class Config:
@ -141,6 +148,28 @@ class DirectMessageResponse(DirectMessageBase):
sender_profile_picture: Optional[str] = None sender_profile_picture: Optional[str] = None
snippet: Optional["SnippetResponse"] = None snippet: Optional["SnippetResponse"] = None
reply_to: Optional[dict] = None # Contains replied message info reply_to: Optional[dict] = None # Contains replied message info
attachments: List["DirectMessageAttachmentResponse"] = []
class Config:
from_attributes = True
# Direct Message Attachment Schemas
class DirectMessageAttachmentCreate(BaseModel):
permission: str = "read" # "read" or "write"
class DirectMessageAttachmentResponse(BaseModel):
id: int
filename: str
original_filename: str
mime_type: str
file_size: int
uploaded_at: datetime
direct_message_id: int
upload_permission: Optional[str] = "read"
uploader_id: Optional[int] = None
is_editable: bool = False
class Config: class Config:
from_attributes = True from_attributes = True
@ -296,6 +325,8 @@ class KanbanCardBase(BaseModel):
due_date: Optional[datetime] = None due_date: Optional[datetime] = None
priority: Optional[str] = "medium" priority: Optional[str] = "medium"
labels: Optional[str] = None labels: Optional[str] = None
estimated_time: Optional[int] = None
actual_time: Optional[int] = None
class KanbanCardCreate(KanbanCardBase): class KanbanCardCreate(KanbanCardBase):
@ -310,6 +341,9 @@ class KanbanCardUpdate(BaseModel):
due_date: Optional[datetime] = None due_date: Optional[datetime] = None
priority: Optional[str] = None priority: Optional[str] = None
labels: Optional[str] = None labels: Optional[str] = None
estimated_time: Optional[int] = None
actual_time: Optional[int] = None
is_archived: Optional[bool] = None
class KanbanCardResponse(KanbanCardBase): class KanbanCardResponse(KanbanCardBase):
@ -317,6 +351,11 @@ class KanbanCardResponse(KanbanCardBase):
column_id: int column_id: int
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
attachments_count: int = 0
checklists_count: int = 0
comments_count: int = 0
is_archived: bool = False
assignee: Optional["UserResponse"] = None
class Config: class Config:
from_attributes = True from_attributes = True
@ -388,3 +427,181 @@ class KanbanChecklistWithItems(KanbanChecklistResponse):
class KanbanCardWithChecklists(KanbanCardResponse): class KanbanCardWithChecklists(KanbanCardResponse):
checklists: List[KanbanChecklistWithItems] = [] checklists: List[KanbanChecklistWithItems] = []
# Comment Schemas
class KanbanCardCommentBase(BaseModel):
content: str
class KanbanCardCommentCreate(BaseModel):
content: str
class KanbanCardCommentUpdate(BaseModel):
content: Optional[str] = None
class KanbanCardCommentResponse(KanbanCardCommentBase):
id: int
card_id: int
user_id: int
created_at: datetime
updated_at: datetime
user: Optional[UserResponse] = None
class Config:
from_attributes = True
# Attachment Schemas
class KanbanCardAttachmentBase(BaseModel):
filename: str
original_filename: str
mime_type: str
file_size: int
file_path: str
class KanbanCardAttachmentCreate(BaseModel):
card_id: int
file: bytes # This will be handled by FastAPI's UploadFile
class KanbanCardAttachmentResponse(KanbanCardAttachmentBase):
id: int
card_id: int
uploader_id: int
uploaded_at: datetime
uploader: Optional[UserResponse] = None
class Config:
from_attributes = True
# Time Tracking Schemas
class KanbanTimeEntryBase(BaseModel):
description: Optional[str] = None
start_time: datetime
end_time: Optional[datetime] = None
duration_minutes: Optional[int] = None
class KanbanTimeEntryCreate(BaseModel):
card_id: int
description: Optional[str] = None
class KanbanTimeEntryUpdate(BaseModel):
description: Optional[str] = None
end_time: Optional[datetime] = None
class KanbanTimeEntryResponse(KanbanTimeEntryBase):
id: int
card_id: int
user_id: int
is_running: bool
created_at: datetime
user: Optional[UserResponse] = None
class Config:
from_attributes = True
# Custom Field Schemas
class KanbanCustomFieldBase(BaseModel):
name: str
field_type: str
options: Optional[str] = None
is_required: bool = False
position: int = 0
class KanbanCustomFieldCreate(KanbanCustomFieldBase):
board_id: int
class KanbanCustomFieldUpdate(BaseModel):
name: Optional[str] = None
field_type: Optional[str] = None
options: Optional[str] = None
is_required: Optional[bool] = None
position: Optional[int] = None
class KanbanCustomFieldResponse(KanbanCustomFieldBase):
id: int
board_id: int
created_at: datetime
class Config:
from_attributes = True
class KanbanCustomFieldValueBase(BaseModel):
value: str
class KanbanCustomFieldValueCreate(KanbanCustomFieldValueBase):
field_id: int
card_id: int
class KanbanCustomFieldValueUpdate(BaseModel):
value: Optional[str] = None
class KanbanCustomFieldValueResponse(KanbanCustomFieldValueBase):
id: int
field_id: int
card_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
# Template Schemas
class KanbanCardTemplateBase(BaseModel):
name: str
description: Optional[str] = None
template_data: str
is_default: bool = False
class KanbanCardTemplateCreate(KanbanCardTemplateBase):
board_id: int
class KanbanCardTemplateUpdate(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
template_data: Optional[str] = None
is_default: Optional[bool] = None
class KanbanCardTemplateResponse(KanbanCardTemplateBase):
id: int
board_id: int
created_at: datetime
class Config:
from_attributes = True
# Extended Card Response with all features
class KanbanCardExtendedResponse(KanbanCardResponse):
estimated_time: Optional[int] = None
actual_time: Optional[int] = None
comments: List[KanbanCardCommentResponse] = []
attachments: List[KanbanCardAttachmentResponse] = []
time_entries: List[KanbanTimeEntryResponse] = []
custom_field_values: List[KanbanCustomFieldValueResponse] = []
checklists: List[KanbanChecklistWithItems] = []
class KanbanBoardExtendedResponse(KanbanBoardResponse):
custom_fields: List[KanbanCustomFieldResponse] = []
templates: List[KanbanCardTemplateResponse] = []

View File

@ -1,21 +1,33 @@
from fastapi import WebSocket, WebSocketDisconnect from fastapi import WebSocket, WebSocketDisconnect
from typing import Dict, List from typing import Dict, List, Optional
import json import json
import time
from datetime import datetime, timedelta
class ConnectionManager: class ConnectionManager:
def __init__(self): def __init__(self):
# Maps channel_id to list of WebSocket connections # Maps channel_id to list of WebSocket connections
self.active_connections: Dict[int, List[WebSocket]] = {} self.active_connections: Dict[int, List[WebSocket]] = {}
# Maps user_id to their connection info
self.user_connections: Dict[int, Dict] = {}
async def connect(self, websocket: WebSocket, channel_id: int): async def connect(self, websocket: WebSocket, channel_id: int, user_id: int):
"""Accept a new WebSocket connection for a channel""" """Accept a new WebSocket connection for a channel"""
await websocket.accept() await websocket.accept()
if channel_id not in self.active_connections: if channel_id not in self.active_connections:
self.active_connections[channel_id] = [] self.active_connections[channel_id] = []
self.active_connections[channel_id].append(websocket) self.active_connections[channel_id].append(websocket)
def disconnect(self, websocket: WebSocket, channel_id: int): # Track user connection
self.user_connections[user_id] = {
'websocket': websocket,
'channel_id': channel_id,
'last_activity': time.time(),
'connected_at': time.time()
}
def disconnect(self, websocket: WebSocket, channel_id: int, user_id: int):
"""Remove a WebSocket connection""" """Remove a WebSocket connection"""
if channel_id in self.active_connections: if channel_id in self.active_connections:
if websocket in self.active_connections[channel_id]: if websocket in self.active_connections[channel_id]:
@ -25,6 +37,30 @@ class ConnectionManager:
if not self.active_connections[channel_id]: if not self.active_connections[channel_id]:
del self.active_connections[channel_id] del self.active_connections[channel_id]
# Remove user connection
if user_id in self.user_connections:
del self.user_connections[user_id]
def update_activity(self, user_id: int):
"""Update last activity time for a user"""
if user_id in self.user_connections:
self.user_connections[user_id]['last_activity'] = time.time()
def get_user_status(self, user_id: int) -> str:
"""Get user online status"""
if user_id not in self.user_connections:
return 'offline'
# User is online as long as they have an active connection
return 'online'
def get_all_user_statuses(self) -> Dict[int, str]:
"""Get status for all users"""
statuses = {}
for user_id in self.user_connections:
statuses[user_id] = self.get_user_status(user_id)
return statuses
async def send_personal_message(self, message: str, websocket: WebSocket): async def send_personal_message(self, message: str, websocket: WebSocket):
"""Send a message to a specific WebSocket""" """Send a message to a specific WebSocket"""
await websocket.send_text(message) await websocket.send_text(message)
@ -42,9 +78,43 @@ class ConnectionManager:
# Mark for removal if send fails # Mark for removal if send fails
disconnected.append(connection) disconnected.append(connection)
# Remove disconnected clients # Also broadcast to channel 0 (global listeners) for messages
if message.get("type") in ["message", "direct_message"] and 0 in self.active_connections:
for connection in self.active_connections[0]:
try:
await connection.send_text(message_str)
except Exception:
pass
async def broadcast_user_status_update(self, user_id: int, status: str):
"""Broadcast user status update to all connected clients"""
message = {
"type": "user_status_update",
"user_id": user_id,
"status": status,
"timestamp": time.time()
}
# Broadcast to all channels (presence connections are on channel 0)
for channel_id in self.active_connections:
message_str = json.dumps(message)
disconnected = []
for connection in self.active_connections[channel_id]:
try:
await connection.send_text(message_str)
except Exception:
disconnected.append(connection)
# Clean up disconnected clients
for connection in disconnected: for connection in disconnected:
self.disconnect(connection, channel_id) user_id_to_remove = None
for uid, conn_info in self.user_connections.items():
if conn_info['websocket'] == connection:
user_id_to_remove = uid
break
if user_id_to_remove:
self.disconnect(connection, channel_id, user_id_to_remove)
# Global connection manager instance # Global connection manager instance

View File

@ -0,0 +1,91 @@
#!/usr/bin/env python3
"""
Migration script to migrate is_admin to role column in user table
"""
import sys
import os
# Add parent directory to path to import app modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from app.database import engine
def migrate():
"""Migrate is_admin column to role column in user table"""
with engine.connect() as conn:
# Check if role column already exists
result = conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='user' AND column_name='role'
"""))
if result.fetchone():
print("✅ Column 'role' already exists in user table")
# Check if is_admin column still exists
result2 = conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name='user' AND column_name='is_admin'
"""))
if result2.fetchone():
# Migrate existing admin users
conn.execute(text("""
UPDATE "user" SET role = 'ADMIN' WHERE is_admin = true
"""))
# Set default role for non-admin users
conn.execute(text("""
UPDATE "user" SET role = 'USER' WHERE role IS NULL OR role = 'user'
"""))
# Drop the old is_admin column
conn.execute(text("""
ALTER TABLE "user" DROP COLUMN is_admin
"""))
conn.commit()
print("✅ Migrated existing data and dropped is_admin column")
else:
# Correct any wrong values
conn.execute(text("""
UPDATE "user" SET role = 'ADMIN' WHERE role = 'admin'
"""))
conn.execute(text("""
UPDATE "user" SET role = 'USER' WHERE role = 'user'
"""))
conn.execute(text("""
UPDATE "user" SET role = 'SUPERADMIN' WHERE role = 'superadmin'
"""))
conn.commit()
print("✅ Corrected role values to enum names")
return
# Add role column
conn.execute(text("""
ALTER TABLE "user" ADD COLUMN role VARCHAR(20) DEFAULT 'USER'
"""))
# Migrate existing admin users
conn.execute(text("""
UPDATE "user" SET role = 'ADMIN' WHERE is_admin = true
"""))
# Set default role for non-admin users
conn.execute(text("""
UPDATE "user" SET role = 'USER' WHERE role IS NULL
"""))
# Drop the old is_admin column
conn.execute(text("""
ALTER TABLE "user" DROP COLUMN is_admin
"""))
conn.commit() # Commit the changes
print("✅ Successfully migrated is_admin to role column")
print("✅ Existing admins have been assigned 'admin' role")
print("✅ Other users have been assigned 'user' role")
if __name__ == "__main__":
migrate()

View File

@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""
Script to set Ronny's role to SUPERADMIN
"""
import sys
import os
# Add parent directory to path to import app modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import text
from app.database import engine
def set_ronny_superadmin():
"""Set Ronny's role to SUPERADMIN"""
with engine.connect() as conn:
# Update Ronny's role
conn.execute(text("""
UPDATE "user" SET role = 'SUPERADMIN' WHERE username = 'Ronny'
"""))
conn.commit()
print("✅ Ronny's role updated to SUPERADMIN")
if __name__ == "__main__":
set_ronny_superadmin()

View File

@ -9,11 +9,16 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"axios": "^1.6.2",
"mdi-react": "^9.4.0",
"prism-react-renderer": "^1.3.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.0", "react-router-dom": "^6.20.0"
"axios": "^1.6.2",
"prism-react-renderer": "^1.3.5"
}, },
"devDependencies": { "devDependencies": {
"@types/react": "^18.2.43", "@types/react": "^18.2.43",

View File

@ -1,6 +1,7 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext'; import { useAuth } from './contexts/AuthContext';
import { isAdmin } from './types';
import Login from './components/Auth/Login'; import Login from './components/Auth/Login';
import Register from './components/Auth/Register'; import Register from './components/Auth/Register';
import ChatView from './components/Chat/ChatView'; import ChatView from './components/Chat/ChatView';
@ -22,7 +23,7 @@ const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <Navigate to="/login" />; return <Navigate to="/login" />;
} }
if (!user?.is_admin) { if (!isAdmin(user)) {
return <Navigate to="/" />; return <Navigate to="/" />;
} }
@ -38,7 +39,7 @@ const AppContent: React.FC = () => {
// Speichere nur Pfade innerhalb der geschützten Bereiche (nicht login/register) // Speichere nur Pfade innerhalb der geschützten Bereiche (nicht login/register)
if (isAuthenticated && !location.pathname.startsWith('/login') && !location.pathname.startsWith('/register')) { if (isAuthenticated && !location.pathname.startsWith('/login') && !location.pathname.startsWith('/register')) {
// Prüfe Admin-Berechtigung für Admin-Pfade // Prüfe Admin-Berechtigung für Admin-Pfade
if (location.pathname.startsWith('/admin') && !user?.is_admin) { if (location.pathname.startsWith('/admin') && !isAdmin(user)) {
return; // Speichere keine ungültigen Admin-Pfade return; // Speichere keine ungültigen Admin-Pfade
} }
localStorage.setItem('lastVisitedPath', location.pathname + location.search); localStorage.setItem('lastVisitedPath', location.pathname + location.search);
@ -85,7 +86,7 @@ const RouteRestorer: React.FC = () => {
if (lastVisitedPath && lastVisitedPath !== '/') { if (lastVisitedPath && lastVisitedPath !== '/') {
// Prüfe, ob der Pfad gültig ist // Prüfe, ob der Pfad gültig ist
const isAdminPath = lastVisitedPath.startsWith('/admin'); const isAdminPath = lastVisitedPath.startsWith('/admin');
if (isAdminPath && !user.is_admin) { if (isAdminPath && !isAdmin(user)) {
// Benutzer hat keinen Admin-Zugriff, bleibe auf der Hauptseite // Benutzer hat keinen Admin-Zugriff, bleibe auf der Hauptseite
localStorage.removeItem('lastVisitedPath'); localStorage.removeItem('lastVisitedPath');
sessionStorage.setItem('routeRestored', 'true'); sessionStorage.setItem('routeRestored', 'true');

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import api, { adminLanguagesAPI, adminTranslationsAPI, snippetsAPI } from '../../services/api'; import api, { adminLanguagesAPI, adminTranslationsAPI, snippetsAPI } from '../../services/api';
import type { import type {
Channel, Channel,
@ -8,6 +9,7 @@ import type {
TranslationGroup, TranslationGroup,
User, User,
} from '../../types'; } from '../../types';
import { UserRole, isSuperAdmin } from '../../types';
type TabKey = 'users' | 'departments' | 'channels' | 'snippets' | 'languages'; type TabKey = 'users' | 'departments' | 'channels' | 'snippets' | 'languages';
@ -26,6 +28,7 @@ type SnippetAccessEntry = {
}; };
const AdminPanel: React.FC = () => { const AdminPanel: React.FC = () => {
const { user: currentUser } = useAuth();
const [activeTab, setActiveTab] = useState<TabKey>('users'); const [activeTab, setActiveTab] = useState<TabKey>('users');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -65,6 +68,11 @@ const AdminPanel: React.FC = () => {
const [newChannelDesc, setNewChannelDesc] = useState(''); const [newChannelDesc, setNewChannelDesc] = useState('');
const [channelDeptId, setChannelDeptId] = useState<number | null>(null); const [channelDeptId, setChannelDeptId] = useState<number | null>(null);
const [editingChannel, setEditingChannel] = useState<Channel | null>(null);
const [editChannelName, setEditChannelName] = useState('');
const [editChannelDesc, setEditChannelDesc] = useState('');
const [editChannelDeptId, setEditChannelDeptId] = useState<number | null>(null);
const [selectedSnippetId, setSelectedSnippetId] = useState<number | null>(null); const [selectedSnippetId, setSelectedSnippetId] = useState<number | null>(null);
const [snippetAccess, setSnippetAccess] = useState<SnippetAccessEntry[]>([]); const [snippetAccess, setSnippetAccess] = useState<SnippetAccessEntry[]>([]);
@ -252,14 +260,18 @@ const AdminPanel: React.FC = () => {
usersLoaded, usersLoaded,
]); ]);
const toggleAdmin = useCallback( const updateUserRole = useCallback(
async (userId: number, isAdmin: boolean) => { async (userId: number, newRole: UserRole) => {
setError(null); setError(null);
try { try {
await api.patch(`/admin/users/${userId}/admin`, null, { params: { is_admin: !isAdmin } }); await api.patch(`/admin/users/${userId}/role`, { role: newRole });
setUsers((prev) => prev.map((user) => (user.id === userId ? { ...user, is_admin: !isAdmin } : user))); setUsers((prev) => {
const user = prev.find(u => u.id === userId);
setGlobalError(`Rolle von ${user?.username} wurde zu ${newRole} geändert.`);
return prev.map((user) => (user.id === userId ? { ...user, role: newRole } : user));
});
} catch (err) { } catch (err) {
setGlobalError('Admin-Status konnte nicht geändert werden.'); setGlobalError('Rolle konnte nicht geändert werden.');
} }
}, },
[setGlobalError] [setGlobalError]
@ -463,6 +475,45 @@ const AdminPanel: React.FC = () => {
[setGlobalError] [setGlobalError]
); );
const startEditChannel = useCallback((channel: Channel) => {
setEditingChannel(channel);
setEditChannelName(channel.name);
setEditChannelDesc(channel.description || '');
setEditChannelDeptId(channel.department_id);
}, []);
const cancelEditChannel = useCallback(() => {
setEditingChannel(null);
setEditChannelName('');
setEditChannelDesc('');
setEditChannelDeptId(null);
}, []);
const updateChannel = useCallback(
async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editingChannel || !editChannelName.trim() || !editChannelDeptId) {
setGlobalError('Bitte geben Sie einen Namen an und wählen Sie eine Abteilung.');
return;
}
setError(null);
try {
const response = await api.put<Channel>(`/admin/channels/${editingChannel.id}`, {
name: editChannelName.trim(),
description: editChannelDesc.trim() || undefined,
department_id: editChannelDeptId,
});
setChannels((prev) =>
prev.map((channel) => (channel.id === editingChannel.id ? response.data : channel))
);
cancelEditChannel();
} catch (err) {
setGlobalError('Channel konnte nicht aktualisiert werden.');
}
},
[editingChannel, editChannelName, editChannelDesc, editChannelDeptId, setGlobalError, cancelEditChannel]
);
const toggleSnippetAccess = useCallback( const toggleSnippetAccess = useCallback(
async (snippetId: number, departmentId: number, enabled: boolean) => { async (snippetId: number, departmentId: number, enabled: boolean) => {
setError(null); setError(null);
@ -672,7 +723,11 @@ const AdminPanel: React.FC = () => {
{user.full_name || '-'} {user.full_name || '-'}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-sm"> <td className="px-4 py-3 whitespace-nowrap text-sm">
{user.is_admin ? ( {user.role === 'superadmin' ? (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
SuperAdmin
</span>
) : user.role === 'admin' ? (
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800"> <span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
Admin Admin
</span> </span>
@ -683,12 +738,19 @@ const AdminPanel: React.FC = () => {
)} )}
</td> </td>
<td className="px-4 py-3 whitespace-nowrap text-xs"> <td className="px-4 py-3 whitespace-nowrap text-xs">
<button {isSuperAdmin(currentUser) && user.id !== currentUser?.id ? (
onClick={() => toggleAdmin(user.id, user.is_admin)} <select
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" value={user.role}
onChange={(e) => updateUserRole(user.id, e.target.value as UserRole)}
className="text-xs border border-gray-300 rounded px-2 py-1 bg-white dark:bg-gray-700 dark:border-gray-600"
> >
{user.is_admin ? 'Admin entfernen' : 'Admin machen'} <option value={UserRole.USER}>User</option>
</button> <option value={UserRole.ADMIN}>Admin</option>
<option value={UserRole.SUPERADMIN}>SuperAdmin</option>
</select>
) : (
<span className="text-gray-500">-</span>
)}
</td> </td>
</tr> </tr>
))} ))}
@ -1026,7 +1088,72 @@ const AdminPanel: React.FC = () => {
<div className="space-y-2"> <div className="space-y-2">
{channels.map((channel) => { {channels.map((channel) => {
const dept = departments.find((item) => item.id === channel.department_id); const dept = departments.find((item) => item.id === channel.department_id);
return ( const isEditing = editingChannel?.id === channel.id;
return isEditing ? (
<div key={channel.id} className="p-4 border border-blue-300 dark:border-blue-600 rounded-md bg-blue-50 dark:bg-blue-900/20">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-3">
Channel bearbeiten
</h3>
<form onSubmit={updateChannel} className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Channel-Name
</label>
<input
type="text"
value={editChannelName}
onChange={(event) => setEditChannelName(event.target.value)}
required
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Beschreibung
</label>
<textarea
value={editChannelDesc}
onChange={(event) => setEditChannelDesc(event.target.value)}
rows={2}
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
Abteilung
</label>
<select
value={editChannelDeptId ?? ''}
onChange={(event) => setEditChannelDeptId(event.target.value ? Number(event.target.value) : null)}
required
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
>
<option value="">-- Abteilung wählen --</option>
{departments.map((dept) => (
<option key={dept.id} value={dept.id}>
{dept.name}
</option>
))}
</select>
</div>
<div className="flex gap-2">
<button
type="submit"
className="flex-1 bg-blue-600 text-white px-3 py-2 rounded-md hover:bg-blue-700 text-sm"
>
Speichern
</button>
<button
type="button"
onClick={cancelEditChannel}
className="flex-1 bg-gray-300 dark:bg-gray-600 text-gray-900 dark:text-white px-3 py-2 rounded-md hover:bg-gray-400 dark:hover:bg-gray-500 text-sm"
>
Abbrechen
</button>
</div>
</form>
</div>
) : (
<div <div
key={channel.id} key={channel.id}
className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-md" className="flex flex-col md:flex-row md:items-center md:justify-between gap-3 p-3 border border-gray-200 dark:border-gray-700 rounded-md"
@ -1042,6 +1169,13 @@ const AdminPanel: React.FC = () => {
Abteilung: {dept?.name || `ID ${channel.department_id}`} Abteilung: {dept?.name || `ID ${channel.department_id}`}
</p> </p>
</div> </div>
<div className="flex gap-2">
<button
onClick={() => startEditChannel(channel)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 px-3 py-1 text-xs border border-blue-600 dark:border-blue-400 rounded-md"
>
Bearbeiten
</button>
<button <button
onClick={() => deleteChannel(channel.id, channel.name)} onClick={() => deleteChannel(channel.id, channel.name)}
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 px-3 py-1 text-xs border border-red-600 dark:border-red-400 rounded-md" className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 px-3 py-1 text-xs border border-red-600 dark:border-red-400 rounded-md"
@ -1049,6 +1183,7 @@ const AdminPanel: React.FC = () => {
Löschen Löschen
</button> </button>
</div> </div>
</div>
); );
})} })}
</div> </div>

View File

@ -71,7 +71,7 @@ const Login: React.FC = () => {
<p className="mt-4 text-center text-gray-600 dark:text-gray-400"> <p className="mt-4 text-center text-gray-600 dark:text-gray-400">
Don't have an account?{' '} Don't have an account?{' '}
<Link to="/register" className="text-indigo-600 dark:text-indigo-400 hover:underline"> <Link to="/register" className="text-blue-600 dark:text-blue-400 hover:underline">
Register Register
</Link> </Link>
</p> </p>

View File

@ -98,7 +98,7 @@ const Register: React.FC = () => {
<p className="mt-4 text-center text-gray-600 dark:text-gray-400"> <p className="mt-4 text-center text-gray-600 dark:text-gray-400">
Already have an account?{' '} Already have an account?{' '}
<Link to="/login" className="text-indigo-600 dark:text-indigo-400 hover:underline"> <Link to="/login" className="text-blue-600 dark:text-blue-400 hover:underline">
Login Login
</Link> </Link>
</p> </p>

View File

@ -6,13 +6,15 @@ import MessageInput from './MessageInput';
import Sidebar from './Sidebar'; import Sidebar from './Sidebar';
import DirectMessagesSidebar from './DirectMessagesSidebar'; import DirectMessagesSidebar from './DirectMessagesSidebar';
import DirectMessageView from './DirectMessageView'; import DirectMessageView from './DirectMessageView';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
const ChatView: React.FC = () => { const ChatView: React.FC = () => {
const [channels, setChannels] = useState<Channel[]>([]); const [channels, setChannels] = useState<Channel[]>([]);
const [departments, setDepartments] = useState<Department[]>([]); const [departments, setDepartments] = useState<Department[]>([]);
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null); const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
const [selectedUser, setSelectedUser] = useState<User | null>(null); const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [replyTo, setReplyTo] = useState<{ id: number; content: string; sender_username: string } | null>(null); const [replyTo, setReplyTo] = useState<{ id: number; content: string; sender_username: string; sender_full_name?: string } | null>(null);
const { markChannelAsRead, markDirectMessageAsRead, setActiveChannel, setActiveDirectMessage } = useUnreadMessages();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@ -55,21 +57,22 @@ const ChatView: React.FC = () => {
onSelectChannel={(channel) => { onSelectChannel={(channel) => {
setSelectedChannel(channel); setSelectedChannel(channel);
setSelectedUser(null); setSelectedUser(null);
setActiveChannel(channel.id);
setActiveDirectMessage(null);
markChannelAsRead(channel.id);
}} }}
/> />
<div className="flex-1 flex flex-col"> <div className="flex-1 flex flex-col">
{selectedChannel ? ( {selectedChannel ? (
<> <>
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-2"> <div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-0.5">
<h2 className="text-base font-semibold text-gray-900 dark:text-white"> <h2 className="text-base font-semibold text-gray-900 dark:text-white">
# {selectedChannel.name} # {selectedChannel.name}
</h2> </h2>
{selectedChannel.description && ( <p className="text-xs text-gray-600 dark:text-gray-400 mt-0 h-5">
<p className="text-xs text-gray-600 dark:text-gray-400"> {selectedChannel.description || ''}
{selectedChannel.description}
</p> </p>
)}
</div> </div>
<MessageList <MessageList
@ -110,6 +113,9 @@ const ChatView: React.FC = () => {
onSelectUser={(user) => { onSelectUser={(user) => {
setSelectedUser(user); setSelectedUser(user);
setSelectedChannel(null); setSelectedChannel(null);
setActiveDirectMessage(user.id);
setActiveChannel(null);
markDirectMessageAsRead(user.id);
}} }}
/> />
</div> </div>

View File

@ -0,0 +1,249 @@
import React, { useState } from 'react';
import { directMessagesAPI } from '../../services/api';
import SnippetPicker from '../Snippets/SnippetPicker';
import type { Snippet } from '../../types';
import { useToast } from '../../contexts/ToastContext';
interface DirectMessageInputProps {
userId: number;
onMessageSent?: () => void;
replyTo?: { id: number; content: string; sender_username: string; sender_full_name?: string } | null;
onCancelReply?: () => void;
}
const DirectMessageInput: React.FC<DirectMessageInputProps> = ({ userId, onMessageSent, replyTo, onCancelReply }) => {
const { addToast } = useToast();
const [content, setContent] = useState('');
const [selectedSnippet, setSelectedSnippet] = useState<Snippet | null>(null);
const [showSnippetPicker, setShowSnippetPicker] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [sending, setSending] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const emojis = [
'😀', '😂', '😊', '😍', '🥰', '😎', '🤔', '🤗', '🤩', '😅', '😇', '🙃',
'😉', '😋', '😜', '🤪', '😏', '😌', '😴', '🥳', '🤓', '🧐', '😳', '😱',
'😭', '😤', '😡', '🤯', '😶', '🙄', '👍', '👎', '👏', '🙏', '💪', '👋',
'🤝', '✌️', '🤞', '👌', '🤘', '🖖', '❤️', '💕', '💖', '💗', '💙', '💚',
'💛', '🧡', '💜', '🤎', '🖤', '🤍', '💯', '💢', '💥', '💫', '✨', '🌟',
'⭐', '🔥', '💧', '💨', '🌈', '☀️', '🌙', '⚡', '☁️', '🎉', '🎊', '🎈',
'🎁', '🏆', '🥇', '🥈', '🥉', '⚽', '🏀', '🎯', '🎮', '🎲', '🚀', '✈️',
'🚗', '🏠', '🏢', '🗼', '🌍', '🗺️', '🧭', '⏰', '📱', '💻', '⌨️', '🖱️',
'📷', '📚', '📝', '✏️', '📌', '📎', '🔗', '📧', '📨', '📮', '🔔', '🔕',
'✅', '❌', '⭕', '✔️', '💬', '💭', '🍕', '🍔', '🍟', '🍿', '☕', '🍺'
];
const handleSend = async () => {
if (!content.trim() && !selectedSnippet && !selectedFile) return;
setSending(true);
try {
if (selectedFile) {
// Upload file with message using FormData and direct fetch (not API client)
// because API client would double the /api/ prefix
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('content', content || `Shared file: ${selectedFile.name}`);
formData.append('permission', 'read');
if (replyTo?.id) {
formData.append('reply_to_id', replyTo.id.toString());
}
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const token = localStorage.getItem('token');
const response = await fetch(
`${apiUrl}/direct-messages/${userId}/upload`,
{
method: 'POST',
body: formData,
headers: {
'Authorization': `Bearer ${token}`,
},
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
throw new Error(errorData.detail || `Upload failed: ${response.status}`);
}
const result = await response.json();
console.log('File uploaded successfully:', result);
} else {
// Send regular message
await directMessagesAPI.create({
content: content || `Shared snippet: ${selectedSnippet?.title}`,
receiver_id: userId,
snippet_id: selectedSnippet?.id,
reply_to_id: replyTo?.id,
});
}
setContent('');
setSelectedSnippet(null);
setSelectedFile(null);
if (onCancelReply) onCancelReply();
if (onMessageSent) onMessageSent();
} catch (error) {
console.error('Failed to send message:', error);
addToast('Nachricht konnte nicht gesendet werden', 'error');
} finally {
setSending(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) {
setSelectedFile(e.target.files[0]);
}
};
return (
<div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3">
{replyTo && (
<div className="mb-2 p-2 bg-blue-50 dark:bg-blue-900 rounded flex items-start justify-between">
<div className="flex-1">
<div className="text-xs font-medium text-blue-900 dark:text-blue-100">
Replying to {replyTo.sender_full_name || replyTo.sender_username}
</div>
<div className="text-xs text-blue-700 dark:text-blue-200 mt-0.5 truncate">
{replyTo.content}
</div>
</div>
<button
onClick={onCancelReply}
className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm ml-2"
>
</button>
</div>
)}
{selectedSnippet && (
<div className="mb-2 p-2 bg-blue-100 dark:bg-blue-900 rounded flex items-center justify-between">
<span className="text-xs text-blue-900 dark:text-blue-100">
{selectedSnippet.title} ({selectedSnippet.language})
</span>
<button
onClick={() => setSelectedSnippet(null)}
className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm"
>
</button>
</div>
)}
{selectedFile && (
<div className="mb-2 p-2 bg-blue-100 dark:bg-blue-900 rounded flex items-center justify-between">
<span className="text-xs text-blue-900 dark:text-blue-100">
{selectedFile.name} ({(selectedFile.size / 1024).toFixed(2)} KB)
</span>
<button
onClick={() => setSelectedFile(null)}
className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm"
>
</button>
</div>
)}
<div className="flex items-stretch space-x-2">
<button
onClick={() => setShowSnippetPicker(true)}
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-sm self-center"
title="Insert snippet"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/>
</svg>
</button>
<label className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-sm self-center cursor-pointer"
title="Datei anhängen">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/>
</svg>
<input
type="file"
onChange={handleFileSelect}
className="hidden"
/>
</label>
<div className="relative self-center">
<button
onClick={() => setShowEmojiPicker(!showEmojiPicker)}
className="p-2 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-sm"
title="Add emoji"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z"/>
</svg>
</button>
{showEmojiPicker && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setShowEmojiPicker(false)}
/>
<div className="absolute bottom-full mb-2 left-0 z-20 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-xl p-2 w-80 max-h-64 overflow-y-auto">
<div className="grid grid-cols-12 gap-1">
{emojis.map((emoji, idx) => (
<button
key={idx}
onClick={() => {
setContent(content + emoji);
setShowEmojiPicker(false);
}}
className="text-lg hover:bg-gray-100 dark:hover:bg-gray-700 rounded p-1 transition-colors flex items-center justify-center"
>
{emoji}
</button>
))}
</div>
</div>
</>
)}
</div>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none"
rows={2}
/>
<button
onClick={handleSend}
disabled={sending || (!content.trim() && !selectedSnippet && !selectedFile)}
className="px-4 text-sm bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white rounded"
>
Send
</button>
</div>
{showSnippetPicker && (
<SnippetPicker
onSelect={(snippet) => {
setSelectedSnippet(snippet);
setShowSnippetPicker(false);
}}
onClose={() => setShowSnippetPicker(false)}
/>
)}
</div>
);
};
export default DirectMessageInput;

View File

@ -1,8 +1,11 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { directMessagesAPI } from '../../services/api'; import { directMessagesAPI, getApiUrl } from '../../services/api';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import type { User } from '../../types'; import type { User } from '../../types';
import { useToast } from '../../contexts/ToastContext'; import { useUserStatus } from '../../contexts/UserStatusContext';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
import UserStatusIndicator from '../common/UserStatusIndicator';
import DirectMessageInput from './DirectMessageInput';
interface DirectMessage { interface DirectMessage {
id: number; id: number;
@ -16,6 +19,15 @@ interface DirectMessage {
created_at: string; created_at: string;
is_read: boolean; is_read: boolean;
snippet?: any; snippet?: any;
reply_to?: any;
attachments?: Array<{
id: number;
filename: string;
original_filename: string;
mime_type: string;
file_size: number;
uploaded_at: string;
}>;
} }
interface DirectMessageViewProps { interface DirectMessageViewProps {
@ -23,13 +35,15 @@ interface DirectMessageViewProps {
} }
const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => { const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
const { addToast } = useToast(); const { getUserStatus } = useUserStatus();
const [messages, setMessages] = useState<DirectMessage[]>([]); const [messages, setMessages] = useState<DirectMessage[]>([]);
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const [firstUnreadIndex, setFirstUnreadIndex] = useState<number | null>(null);
const { hasUnreadDirectMessage } = useUnreadMessages();
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
const [replyTo, setReplyTo] = useState<{ id: number; content: string; sender_username: string; sender_full_name?: string } | null>(null);
useEffect(() => { useEffect(() => {
loadMessages(); loadMessages();
@ -37,11 +51,19 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
// Set up WebSocket for real-time updates // Set up WebSocket for real-time updates
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token && currentUser) { if (token && currentUser) {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // Use API URL to determine WebSocket host
const wsHost = import.meta.env.VITE_WS_URL || `${wsProtocol}//localhost:8000`; const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const ws = new WebSocket(`${wsHost}/ws/${-currentUser.id}?token=${token}`); const url = new URL(apiUrl);
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${url.host}/api/ws/${-currentUser.id}?token=${token}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('DirectMessage WebSocket connected for user:', currentUser.id);
};
ws.onmessage = (event) => { ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'direct_message') { if (data.type === 'direct_message') {
// Only add if message is from/to the selected user // Only add if message is from/to the selected user
@ -50,9 +72,21 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
(msg.sender_id === user.id && msg.receiver_id === currentUser.id) || (msg.sender_id === user.id && msg.receiver_id === currentUser.id) ||
(msg.sender_id === currentUser.id && msg.receiver_id === user.id) (msg.sender_id === currentUser.id && msg.receiver_id === user.id)
) { ) {
// Message marking is now handled globally in UnreadMessagesContext
setMessages((prevMessages) => [...prevMessages, msg]); setMessages((prevMessages) => [...prevMessages, msg]);
} }
} }
} catch (error) {
console.error('Error parsing DirectMessage WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('DirectMessage WebSocket error for user', currentUser.id, ':', error);
};
ws.onclose = (event) => {
console.log('DirectMessage WebSocket closed for user', currentUser.id, 'Code:', event.code, 'Reason:', event.reason);
}; };
return () => { return () => {
@ -65,6 +99,52 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
scrollToBottom(); scrollToBottom();
}, [messages]); }, [messages]);
// compute first-unread index for direct messages based on is_read or stored last-seen timestamp
useEffect(() => {
try {
const lastSeenIso = localStorage.getItem(`dm_last_seen_${user.id}`);
const lastSeen = lastSeenIso ? new Date(lastSeenIso) : null;
let idx: number | null = null;
// First try to find an explicit unread message addressed to current user
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (currentUser && msg.receiver_id === currentUser.id && !msg.is_read) {
idx = i;
break;
}
}
// If none found, fallback to timestamp-based detection
if (idx === null && lastSeen) {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const created = new Date(msg.created_at);
if (created > lastSeen && !(currentUser && msg.sender_id === currentUser.id)) {
idx = i;
break;
}
}
}
// If still none found but DM is marked unread in context, use first message from other user
if (idx === null && hasUnreadDirectMessage(user.id)) {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!(currentUser && msg.sender_id === currentUser.id)) {
idx = i;
break;
}
}
}
// debug: expose helpful info to browser console to troubleshoot missing marker
// (temporary; can be removed once verified)
setFirstUnreadIndex(idx);
} catch (e) {
setFirstUnreadIndex(null);
}
}, [messages, user.id, currentUser]);
const loadMessages = async () => { const loadMessages = async () => {
try { try {
const data = await directMessagesAPI.getConversation(user.id); const data = await directMessagesAPI.getConversation(user.id);
@ -80,29 +160,13 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}; };
const handleSend = async () => { const handleDeleteMessage = async (messageId: number) => {
if (!content.trim()) return;
setSending(true);
try { try {
await directMessagesAPI.create({ await directMessagesAPI.deleteMessage(messageId);
content, // Remove the message from the UI
receiver_id: user.id, setMessages(messages.filter(m => m.id !== messageId));
});
setContent('');
} catch (error) { } catch (error) {
console.error('Failed to send message:', error); console.error('Failed to delete message:', error);
addToast('Failed to send message', 'error');
} finally {
setSending(false);
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
} }
}; };
@ -129,16 +193,27 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
<> <>
{/* Messages */} {/* Messages */}
<div className="flex-1 overflow-y-auto p-3 space-y-3 bg-gray-50 dark:bg-gray-900"> <div className="flex-1 overflow-y-auto p-3 space-y-3 bg-gray-50 dark:bg-gray-900">
{messages.map((message) => { {messages.map((message, index) => {
const isOwnMessage = message.sender_id === currentUser?.id; const isOwnMessage = message.sender_id === currentUser?.id;
const markerBefore = firstUnreadIndex !== null && firstUnreadIndex === index;
return ( return (
<div key={message.id} className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}> <React.Fragment key={message.id}>
{markerBefore && (
<div className="flex items-center w-full my-3">
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
<div className="px-3 text-xs text-gray-500 dark:text-gray-400">Neue Nachrichten</div>
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
</div>
)}
<div
id={`message-${message.id}`}
className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`}
>
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}> <div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{/* Profile Picture / Initials */}
{message.sender_profile_picture ? ( {message.sender_profile_picture ? (
<img <img
src={`http://localhost:8000/${message.sender_profile_picture}`} src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username} alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0" className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/> />
@ -148,55 +223,164 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
</div> </div>
)} )}
{/* Message Bubble */} <div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'} relative`}>
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline space-x-2 mb-1"> <div className="flex items-baseline space-x-2 mb-1">
<div className="flex items-center space-x-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white"> <span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_username} {message.sender_full_name || message.sender_username || 'Unknown'}
</span> </span>
{message.sender_id !== currentUser?.id && (
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
</div> </div>
<div className={`px-3 py-1 rounded-lg ${ <div className={`px-1 py-1 rounded-lg relative ${
isOwnMessage isOwnMessage
? 'bg-blue-500 text-white rounded-br-none' ? 'bg-blue-500 bg-opacity-80 text-white rounded-br-none'
: 'bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none' : 'bg-white bg-opacity-80 dark:bg-gray-700 dark:bg-opacity-20 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none'
}`}> }`}>
<div className="text-sm whitespace-pre-wrap break-words"> {/* Hover Action Buttons */}
<div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} top-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 px-2`}>
<button
onClick={() => {
setReplyTo({
id: message.id,
content: message.content,
sender_username: message.sender_username || 'Unknown',
sender_full_name: message.sender_full_name
});
}}
className="p-1.5 bg-white dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-full shadow-lg border border-gray-200 dark:border-gray-600"
title="Antworten"
>
<svg className="w-4 h-4 text-gray-700 dark:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
</svg>
</button>
<button
onClick={() => setOpenMenuId(openMenuId === message.id ? null : message.id)}
className="p-1.5 bg-white dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-full shadow-lg border border-gray-200 dark:border-gray-600"
title="Mehr"
>
<svg className="w-4 h-4 text-gray-700 dark:text-gray-300" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
</div>
{openMenuId === message.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setOpenMenuId(null)}
/>
<div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} bottom-full mb-2 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 w-48`}>
<button
onClick={() => {
navigator.clipboard.writeText(message.content);
setOpenMenuId(null);
}}
className="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Kopieren
</button>
{message.sender_id === currentUser?.id && (
<button
onClick={() => {
setOpenMenuId(null);
handleDeleteMessage(message.id);
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
Löschen
</button>
)}
</div>
</>
)}
{message.reply_to && (
<div
className="mt-1 mb-1 pl-3 border-l-2 border-indigo-500 bg-gray-100 dark:bg-gray-800 p-1.5 rounded cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-700"
>
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
{message.reply_to.sender_full_name || message.reply_to.sender_username}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 truncate">
{message.reply_to.content}
</div>
</div>
)}
{message.snippet && (
<div className="mt-2 p-2 bg-gray-900 dark:bg-gray-950 rounded border border-gray-700 dark:border-gray-800">
<div className="flex justify-between items-center mb-1">
<span className="text-xs text-gray-400">
{message.snippet.language}
</span>
<span className="text-xs text-gray-400">
{message.snippet.title}
</span>
</div>
<pre className="text-xs text-gray-100 overflow-x-auto p-2 font-mono">
{message.snippet.content}
</pre>
</div>
)}
{message.attachments && message.attachments.length > 0 && (
<div className="mt-1.5 space-y-1.5">
{message.attachments.map((file) => (
<div key={file.id} className="rounded border border-gray-300 dark:border-gray-600 overflow-hidden bg-gray-100 dark:bg-gray-700">
<div className="flex items-center justify-between p-2 bg-gray-100 dark:bg-gray-700">
<div className="flex items-center space-x-2 flex-1 min-w-0">
<span className="text-lg">📄</span>
<div className="flex-1 min-w-0">
<a
href={getApiUrl(`/files/${file.filename}`)}
download={file.original_filename}
className="text-xs font-medium text-gray-900 dark:text-white hover:underline truncate block"
>
{file.original_filename}
</a>
<div className="text-xs text-gray-600 dark:text-gray-400">
{(file.file_size / 1024).toFixed(2)} KB
</div>
</div>
</div>
</div>
</div>
))}
</div>
)}
{message.content && (
<div className="text-sm whitespace-pre-wrap break-words px-1">
{message.content} {message.content}
</div> </div>
)}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</React.Fragment>
); );
})} })}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
</div> </div>
{/* Input */} {/* Input */}
<div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3"> <DirectMessageInput
<div className="flex items-end space-x-2"> userId={user.id}
<textarea onMessageSent={loadMessages}
value={content} replyTo={replyTo}
onChange={(e) => setContent(e.target.value)} onCancelReply={() => setReplyTo(null)}
onKeyPress={handleKeyPress}
placeholder="Type a message..."
className="flex-1 px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white resize-none"
rows={2}
/> />
<button
onClick={handleSend}
disabled={sending || !content.trim()}
className="px-3 py-2 text-sm bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white rounded"
>
Send
</button>
</div>
</div>
</> </>
); );
}; };

View File

@ -1,7 +1,12 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useUserStatus } from '../../contexts/UserStatusContext';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
import axios from 'axios'; import axios from 'axios';
import { getApiUrl } from '../../services/api';
import type { User } from '../../types'; import type { User } from '../../types';
import UserStatusIndicator from '../common/UserStatusIndicator';
import BlinkingEnvelope from '../common/BlinkingEnvelope';
interface DirectMessagesSidebarProps { interface DirectMessagesSidebarProps {
onSelectUser: (user: User) => void; onSelectUser: (user: User) => void;
@ -18,6 +23,8 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
const [showUserPicker, setShowUserPicker] = useState(false); const [showUserPicker, setShowUserPicker] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const { getUserStatus } = useUserStatus();
const { hasUnreadDirectMessage, markDirectMessageAsUnread } = useUnreadMessages();
useEffect(() => { useEffect(() => {
// Load conversations on mount // Load conversations on mount
@ -27,7 +34,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
const loadConversations = async () => { const loadConversations = async () => {
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const response = await axios.get('http://localhost:8000/direct-messages/conversations', { const response = await axios.get(getApiUrl('/direct-messages/conversations'), {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
@ -49,7 +56,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
setLoading(true); setLoading(true);
try { try {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const response = await axios.get('http://localhost:8000/admin/users', { const response = await axios.get(getApiUrl('/admin/users'), {
headers: { headers: {
Authorization: `Bearer ${token}` Authorization: `Bearer ${token}`
} }
@ -84,7 +91,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
return ( return (
<> <>
<div className="w-52 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 flex flex-col"> <div className="w-52 bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 flex flex-col">
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between"> <div className="px-3 py-3 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Direct Messages</h3> <h3 className="font-semibold text-base text-gray-900 dark:text-white">Direct Messages</h3>
<button <button
onClick={() => { onClick={() => {
@ -112,6 +119,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
<button <button
key={user.id} key={user.id}
onClick={() => onSelectUser(user)} onClick={() => onSelectUser(user)}
onDoubleClick={() => markDirectMessageAsUnread(user.id)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${ className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
selectedUserId === user.id selectedUserId === user.id
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100' ? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
@ -119,8 +127,9 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
}`} }`}
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<span className="w-2 h-2 rounded-full bg-green-500"></span> <UserStatusIndicator status={getUserStatus(user.id)} size="sm" />
<span>{user.username}</span> <span>{user.username}</span>
<BlinkingEnvelope hasNewMessages={hasUnreadDirectMessage(user.id)} />
</div> </div>
{user.full_name && ( {user.full_name && (
<div className="text-xs text-gray-500 dark:text-gray-400 ml-4"> <div className="text-xs text-gray-500 dark:text-gray-400 ml-4">
@ -183,6 +192,11 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
onClick={() => handleAddUser(user)} onClick={() => handleAddUser(user)}
className="w-full text-left p-2.5 border border-gray-200 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-700" className="w-full text-left p-2.5 border border-gray-200 dark:border-gray-700 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
> >
<div className="flex items-center space-x-2">
<UserStatusIndicator status={getUserStatus(user.id)} size="sm" />
<div className="flex items-center space-x-2">
<UserStatusIndicator status={getUserStatus(user.id)} size="sm" />
<div className="flex-1">
<div className="font-medium text-sm text-gray-900 dark:text-white"> <div className="font-medium text-sm text-gray-900 dark:text-white">
{user.username} {user.username}
</div> </div>
@ -196,6 +210,10 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
{user.email} {user.email}
</div> </div>
)} )}
</div>
<BlinkingEnvelope hasNewMessages={hasUnreadDirectMessage(user.id)} />
</div>
</div>
</button> </button>
))} ))}
</div> </div>

View File

@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext';
interface MessageInputProps { interface MessageInputProps {
channelId: number; channelId: number;
replyTo?: { id: number; content: string; sender_username: string } | null; replyTo?: { id: number; content: string; sender_username: string; sender_full_name?: string } | null;
onCancelReply?: () => void; onCancelReply?: () => void;
} }
@ -85,18 +85,18 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
return ( return (
<div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3"> <div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3">
{replyTo && ( {replyTo && (
<div className="mb-2 p-2 bg-indigo-50 dark:bg-indigo-900 rounded flex items-start justify-between"> <div className="mb-2 p-2 bg-blue-50 dark:bg-blue-900 rounded flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="text-xs font-medium text-indigo-900 dark:text-indigo-100"> <div className="text-xs font-medium text-blue-900 dark:text-blue-100">
Replying to {replyTo.sender_username} Replying to {replyTo.sender_full_name || replyTo.sender_username}
</div> </div>
<div className="text-xs text-indigo-700 dark:text-indigo-200 mt-0.5 truncate"> <div className="text-xs text-blue-700 dark:text-blue-200 mt-0.5 truncate">
{replyTo.content} {replyTo.content}
</div> </div>
</div> </div>
<button <button
onClick={onCancelReply} onClick={onCancelReply}
className="text-indigo-900 dark:text-indigo-100 hover:text-red-600 text-sm ml-2" className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm ml-2"
> >
</button> </button>
@ -104,13 +104,13 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
)} )}
{selectedSnippet && ( {selectedSnippet && (
<div className="mb-2 p-2 bg-indigo-100 dark:bg-indigo-900 rounded flex items-center justify-between"> <div className="mb-2 p-2 bg-blue-100 dark:bg-blue-900 rounded flex items-center justify-between">
<span className="text-xs text-indigo-900 dark:text-indigo-100"> <span className="text-xs text-blue-900 dark:text-blue-100">
📋 {selectedSnippet.title} ({selectedSnippet.language}) {selectedSnippet.title} ({selectedSnippet.language})
</span> </span>
<button <button
onClick={() => setSelectedSnippet(null)} onClick={() => setSelectedSnippet(null)}
className="text-indigo-900 dark:text-indigo-100 hover:text-red-600 text-sm" className="text-blue-900 dark:text-blue-100 hover:text-red-600 text-sm"
> >
</button> </button>

View File

@ -1,19 +1,24 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { messagesAPI, filesAPI } from '../../services/api'; import { messagesAPI, filesAPI, getApiUrl } from '../../services/api';
import type { Message } from '../../types'; import type { Message } from '../../types';
import CodeBlock from '../common/CodeBlock'; import CodeBlock from '../common/CodeBlock';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useUserStatus } from '../../contexts/UserStatusContext';
import UserStatusIndicator from '../common/UserStatusIndicator';
interface MessageListProps { interface MessageListProps {
channelId: number; channelId: number;
onReply?: (message: { id: number; content: string; sender_username: string }) => void; onReply?: (message: { id: number; content: string; sender_username: string; sender_full_name?: string }) => void;
} }
const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => { const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
const { user } = useAuth(); const { user } = useAuth();
const { addToast } = useToast(); const { addToast } = useToast();
const { getUserStatus } = useUserStatus();
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [firstUnreadIndex, setFirstUnreadIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [openMenuId, setOpenMenuId] = useState<number | null>(null); const [openMenuId, setOpenMenuId] = useState<number | null>(null);
@ -21,7 +26,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
const messagesEndRef = useRef<HTMLDivElement>(null); const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const MESSAGES_LIMIT = 15; const MESSAGES_LIMIT = 10;
const getInitials = (fullName?: string, username?: string) => { const getInitials = (fullName?: string, username?: string) => {
if (fullName) { if (fullName) {
@ -40,10 +45,11 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
// Set up WebSocket for real-time updates // Set up WebSocket for real-time updates
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (token && channelId > 0) { if (token && channelId > 0) {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; // Use API URL to determine WebSocket host and add /ws path
const wsHost = import.meta.env.VITE_WS_URL || `${wsProtocol}//localhost:8000`; const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const wsUrl = `${wsHost}/ws/${channelId}?token=${token}`; const url = new URL(apiUrl);
console.log('Connecting to WebSocket:', wsUrl); const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${url.host}/api/ws/${channelId}?token=${token}`;
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
wsRef.current = ws; wsRef.current = ws;
@ -53,8 +59,11 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
}; };
ws.onmessage = (event) => { ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.type === 'message') { if (data.type === 'message') {
// Message marking is now handled globally in UnreadMessagesContext
// Add new message immediately to the list, but avoid duplicates // Add new message immediately to the list, but avoid duplicates
setMessages((prevMessages) => { setMessages((prevMessages) => {
// Check if message already exists // Check if message already exists
@ -78,11 +87,13 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
) )
); );
} }
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
}; };
ws.onerror = (error) => { ws.onerror = (error) => {
console.error('WebSocket error for channel', channelId, ':', error); console.error('WebSocket error for channel', channelId, ':', error);
console.error('WebSocket URL was:', wsUrl);
}; };
ws.onclose = (event) => { ws.onclose = (event) => {
@ -110,6 +121,61 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
scrollToBottom(); scrollToBottom();
}, [messages]); }, [messages]);
const { hasUnreadChannel } = useUnreadMessages();
// compute first-unread index based on stored last-seen timestamp for this channel
const computeFirstUnreadIndex = () => {
try {
const lastSeenIso = localStorage.getItem(`channel_last_seen_${channelId}`);
if (!lastSeenIso) {
// if no stored timestamp but channel is marked unread, compute from messages
if (hasUnreadChannel(channelId)) {
let idx2: number | null = null;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
if (!(user && msg.sender_id === user.id)) {
idx2 = i;
break;
}
}
setFirstUnreadIndex(idx2);
} else {
setFirstUnreadIndex(null);
}
return;
}
const lastSeen = new Date(lastSeenIso);
let idx: number | null = null;
for (let i = 0; i < messages.length; i++) {
const msg = messages[i];
const created = new Date(msg.created_at);
// consider unread if created after lastSeen and message is from another user
if (created > lastSeen && !(user && msg.sender_id === user.id)) {
idx = i;
break;
}
}
setFirstUnreadIndex(idx);
} catch (e) {
setFirstUnreadIndex(null);
}
};
useEffect(() => {
computeFirstUnreadIndex();
}, [messages, channelId, user]);
// Listen for lastSeenUpdated event from UnreadMessagesContext to trigger recompute
useEffect(() => {
const handleLastSeenUpdated = (event: CustomEvent) => {
if (event.detail && event.detail.channel_id === channelId) {
computeFirstUnreadIndex();
}
};
window.addEventListener('lastSeenUpdated', handleLastSeenUpdated as EventListener);
return () => window.removeEventListener('lastSeenUpdated', handleLastSeenUpdated as EventListener);
}, [channelId, messages, user, hasUnreadChannel]);
const loadMessages = async (append = false) => { const loadMessages = async (append = false) => {
try { try {
const offset = append ? messages.length : 0; const offset = append ? messages.length : 0;
@ -263,21 +329,22 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<span className="text-xs text-gray-500 dark:text-gray-400">Lade ältere Nachrichten...</span> <span className="text-xs text-gray-500 dark:text-gray-400">Lade ältere Nachrichten...</span>
</div> </div>
)} )}
{messages.map((message) => { {messages.map((message, index) => {
const isOwnMessage = user && message.sender_id === user.id; const isOwnMessage = user && message.sender_id === user.id;
// Deleted message - simple text without bubble (check both deleted and is_deleted) // Deleted message - simple text without bubble (check both deleted and is_deleted)
if (message.deleted || message.is_deleted) { if (message.deleted || message.is_deleted) {
const markerBefore = firstUnreadIndex !== null && firstUnreadIndex === index;
return ( return (
<React.Fragment key={message.id}>
<div <div
key={message.id}
id={`message-${message.id}`} id={`message-${message.id}`}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} p-2`} className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'} p-2`}
> >
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}> <div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{message.sender_profile_picture ? ( {message.sender_profile_picture ? (
<img <img
src={`http://localhost:8000/${message.sender_profile_picture}`} src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username} alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0" className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/> />
@ -288,9 +355,14 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
)} )}
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}> <div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline space-x-2 mb-1"> <div className="flex items-baseline space-x-2 mb-1">
<div className="flex items-center space-x-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white"> <span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_username || 'Unknown'} {message.sender_full_name || message.sender_username || 'Unknown'}
</span> </span>
{message.sender_id !== user?.id && (
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
@ -301,19 +373,35 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
</div> </div>
</div> </div>
</div> </div>
{markerBefore && (
<div className="flex items-center w-full my-3">
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
<div className="px-3 text-xs text-gray-500 dark:text-gray-400">Neue Nachrichten</div>
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
</div>
)}
</React.Fragment>
); );
} }
const markerBefore = firstUnreadIndex !== null && firstUnreadIndex === index;
return ( return (
<React.Fragment key={message.id}>
{markerBefore && (
<div className="flex items-center w-full my-3">
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
<div className="px-3 text-xs text-gray-500 dark:text-gray-400">Neue Nachrichten</div>
<div className="flex-1 h-0.5 bg-gray-300 dark:bg-gray-600" />
</div>
)}
<div <div
key={message.id}
id={`message-${message.id}`} id={`message-${message.id}`}
className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`} className={`group flex ${isOwnMessage ? 'justify-end' : 'justify-start'} hover:bg-gray-100 dark:hover:bg-gray-800 rounded p-2 -m-2 transition-all`}
> >
<div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}> <div className={`flex items-start space-x-2 max-w-[16rem] ${isOwnMessage ? 'flex-row-reverse space-x-reverse' : ''}`}>
{message.sender_profile_picture ? ( {message.sender_profile_picture ? (
<img <img
src={`http://localhost:8000/${message.sender_profile_picture}`} src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username} alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0" className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/> />
@ -325,9 +413,14 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'} relative`}> <div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'} relative`}>
<div className="flex items-baseline space-x-2 mb-1"> <div className="flex items-baseline space-x-2 mb-1">
<div className="flex items-center space-x-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white"> <span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_username || 'Unknown'} {message.sender_full_name || message.sender_username || 'Unknown'}
</span> </span>
{message.sender_id !== user?.id && (
<UserStatusIndicator status={getUserStatus(message.sender_id)} size="sm" />
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400"> <span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} {new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span> </span>
@ -335,8 +428,8 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<div className={`px-1 py-1 rounded-lg relative ${ <div className={`px-1 py-1 rounded-lg relative ${
isOwnMessage isOwnMessage
? 'bg-blue-500 text-white rounded-br-none' ? 'bg-blue-500 bg-opacity-80 text-white rounded-br-none'
: 'bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none' : 'bg-white bg-opacity-80 dark:bg-gray-700 dark:bg-opacity-20 text-gray-800 dark:text-gray-200 border border-gray-200 dark:border-gray-600 rounded-bl-none'
}`}> }`}>
{/* Hover Action Buttons */} {/* Hover Action Buttons */}
<div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} top-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 px-2`}> <div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} top-0 opacity-0 group-hover:opacity-100 transition-opacity flex gap-1 px-2`}>
@ -346,7 +439,8 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
onReply({ onReply({
id: message.id, id: message.id,
content: message.content, content: message.content,
sender_username: message.sender_username || 'Unknown' sender_username: message.sender_username || 'Unknown',
sender_full_name: message.sender_full_name
}); });
}} }}
className="p-1.5 bg-white dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-full shadow-lg border border-gray-200 dark:border-gray-600" className="p-1.5 bg-white dark:bg-gray-700 hover:bg-gray-100 dark:hover:bg-gray-600 rounded-full shadow-lg border border-gray-200 dark:border-gray-600"
@ -375,6 +469,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
onClick={() => setOpenMenuId(null)} onClick={() => setOpenMenuId(null)}
/> />
<div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} bottom-full mb-2 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 w-48`}> <div className={`absolute ${isOwnMessage ? 'left-0 -translate-x-full' : 'right-0 translate-x-full'} bottom-full mb-2 z-20 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-xl py-1 w-48`}>
{!isOwnMessage && (
<button <button
onClick={() => { onClick={() => {
// TODO: Implement private message // TODO: Implement private message
@ -385,6 +480,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
> >
Private Nachricht Private Nachricht
</button> </button>
)}
<button <button
onClick={() => { onClick={() => {
navigator.clipboard.writeText(message.content); navigator.clipboard.writeText(message.content);
@ -415,7 +511,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
onClick={() => message.reply_to && scrollToMessage(message.reply_to.id)} onClick={() => message.reply_to && scrollToMessage(message.reply_to.id)}
> >
<div className="text-xs font-medium text-gray-700 dark:text-gray-300"> <div className="text-xs font-medium text-gray-700 dark:text-gray-300">
{message.reply_to.sender_username} {message.reply_to.sender_full_name || message.reply_to.sender_username}
</div> </div>
<div className="text-xs text-gray-600 dark:text-gray-400 truncate"> <div className="text-xs text-gray-600 dark:text-gray-400 truncate">
{message.reply_to.content} {message.reply_to.content}
@ -734,6 +830,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
</div> </div>
</div> </div>
</div> </div>
</React.Fragment>
); );
})} })}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />

View File

@ -1,11 +1,14 @@
import React from 'react'; import React from 'react';
import type { Channel, Department } from '../../types'; import type { Channel, Department } from '../../types';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
import BlinkingEnvelope from '../common/BlinkingEnvelope';
interface SidebarProps { interface SidebarProps {
channels: Channel[]; channels: Channel[];
departments: Department[]; departments: Department[];
selectedChannel: Channel | null; selectedChannel: Channel | null;
onSelectChannel: (channel: Channel) => void; onSelectChannel: (channel: Channel) => void;
onDeleteChannel?: (channel: Channel) => void;
} }
const Sidebar: React.FC<SidebarProps> = ({ const Sidebar: React.FC<SidebarProps> = ({
@ -13,7 +16,10 @@ const Sidebar: React.FC<SidebarProps> = ({
departments, departments,
selectedChannel, selectedChannel,
onSelectChannel, onSelectChannel,
onDeleteChannel,
}) => { }) => {
const { hasUnreadChannel, markChannelAsUnread } = useUnreadMessages();
// Group channels by department // Group channels by department
const channelsByDept = channels.reduce((acc, channel) => { const channelsByDept = channels.reduce((acc, channel) => {
if (!acc[channel.department_id]) { if (!acc[channel.department_id]) {
@ -23,31 +29,71 @@ const Sidebar: React.FC<SidebarProps> = ({
return acc; return acc;
}, {} as Record<number, Channel[]>); }, {} as Record<number, Channel[]>);
const handleChannelClick = (channel: Channel) => {
onSelectChannel(channel);
};
const handleChannelDoubleClick = (channel: Channel) => {
// Test function: mark as unread on double click
markChannelAsUnread(channel.id);
};
const handleDeleteClick = (channel: Channel, e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteChannel) {
if (window.confirm(`Channel "${channel.name}" und alle seine Inhalte (Nachrichten, Kanban-Board) wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`)) {
onDeleteChannel(channel);
}
}
};
return ( return (
<div className="w-52 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col"> <div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700"> <div className="px-3 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Channels</h3> <h3 className="font-semibold text-base text-gray-900 dark:text-white">Channels</h3>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{departments.map((dept) => ( {departments.map((dept) => (
<div key={dept.id} className="mb-3"> <div key={dept.id} className="mb-3">
<div className="px-3 py-1.5 text-xs font-semibold text-gray-600 dark:text-gray-400"> <div className="px-3 py-1.5 text-sm font-semibold text-gray-600 dark:text-gray-400">
{dept.name} {dept.name}
</div> </div>
{channelsByDept[dept.id]?.map((channel) => ( {channelsByDept[dept.id]?.map((channel) => (
<div key={channel.id} className="relative group">
<button <button
key={channel.id} onClick={() => handleChannelClick(channel)}
onClick={() => onSelectChannel(channel)} onDoubleClick={() => handleChannelDoubleClick(channel)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${ className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 ${
selectedChannel?.id === channel.id selectedChannel?.id === channel.id
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100' ? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
: 'text-gray-700 dark:text-gray-300' : 'text-gray-700 dark:text-gray-300'
}`} }`}
> >
# {channel.name} <div className="flex items-center justify-between">
<span># {channel.name}</span>
<BlinkingEnvelope hasNewMessages={hasUnreadChannel(channel.id)} />
</div>
{channel.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{channel.description}
</div>
)}
</button> </button>
{onDeleteChannel && (
<button
onClick={(e) => handleDeleteClick(channel, e)}
className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-opacity"
title="Channel löschen"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</button>
)}
</div>
))} ))}
</div> </div>
))} ))}

View File

@ -0,0 +1,61 @@
import React from 'react';
interface ConfirmDialogProps {
isOpen: boolean;
title: string;
message: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void;
onCancel: () => void;
}
const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
title,
message,
confirmText = 'Bestätigen',
cancelText = 'Abbrechen',
onConfirm,
onCancel
}) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h3>
</div>
{/* Content */}
<div className="px-6 py-4">
<p className="text-gray-700 dark:text-gray-300">
{message}
</p>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
{cancelText}
</button>
<button
onClick={onConfirm}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 border border-transparent rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-colors"
>
{confirmText}
</button>
</div>
</div>
</div>
);
};
export default ConfirmDialog;

View File

@ -0,0 +1,96 @@
import React, { useState, useEffect } from 'react';
interface CreateCardDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (title: string) => void;
}
const CreateCardDialog: React.FC<CreateCardDialogProps> = ({
isOpen,
onClose,
onSubmit
}) => {
const [title, setTitle] = useState('');
useEffect(() => {
if (isOpen) {
setTitle('');
// Focus the input when dialog opens
setTimeout(() => {
const input = document.getElementById('card-title-input');
if (input) (input as HTMLInputElement).focus();
}, 100);
}
}, [isOpen]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (title.trim()) {
onSubmit(title.trim());
setTitle('');
}
};
const handleClose = () => {
setTitle('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Neue Karte erstellen
</h3>
</div>
{/* Content */}
<form onSubmit={handleSubmit}>
<div className="px-6 py-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Kartentitel
</label>
<input
id="card-title-input"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSubmit(e as any);
}
}}
placeholder="z.B. Feature implementieren"
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end space-x-3">
<button
type="button"
onClick={handleClose}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!title.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-transparent rounded-md hover:bg-blue-700 disabled:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors"
>
Erstellen
</button>
</div>
</form>
</div>
</div>
);
};
export default CreateCardDialog;

View File

@ -0,0 +1,104 @@
import React from 'react';
import type { KanbanCard } from '../../types';
interface KanbanArchiveModalProps {
isOpen: boolean;
onClose: () => void;
archivedCards: KanbanCard[];
onSelectCard: (card: KanbanCard) => void;
}
const KanbanArchiveModal: React.FC<KanbanArchiveModalProps> = ({ isOpen, onClose, archivedCards, onSelectCard }) => {
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-6xl w-full mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Archivierte Tasks
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
{archivedCards.length === 0 ? (
<div className="text-center py-8">
<div className="text-gray-500 dark:text-gray-400">Keine archivierten Tasks gefunden</div>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead className="bg-gray-50 dark:bg-gray-700">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Titel
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Beschreibung
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Erstellt am
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Abgeschlossen am
</th>
</tr>
</thead>
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{archivedCards.map((card) => (
<tr
key={card.id}
className="hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer transition-colors"
onClick={() => {
onSelectCard(card);
onClose();
}}
>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{card.title}
</td>
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-300">
{card.description || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
{formatDate(card.created_at)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-300">
{card.updated_at ? formatDate(card.updated_at) : formatDate(card.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
);
};
export default KanbanArchiveModal;

View File

@ -2,10 +2,13 @@ import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { kanbanAPI, channelsAPI, departmentsAPI } from '../../services/api'; import { kanbanAPI, channelsAPI, departmentsAPI } from '../../services/api';
import type { KanbanBoardWithColumns, KanbanColumn, KanbanCard, Channel, Department } from '../../types'; import type { KanbanBoardWithColumns, KanbanColumn, KanbanCardExtended, Channel, Department } from '../../types';
import KanbanColumnComponent from './KanbanColumn'; import KanbanColumnComponent from './KanbanColumn';
import KanbanCardModal from './KanbanCardModal'; import KanbanCardModal from './KanbanCardModal';
import KanbanArchiveModal from './KanbanArchiveModal';
import KanbanSidebar from './KanbanSidebar'; import KanbanSidebar from './KanbanSidebar';
import CreateCardDialog from './CreateCardDialog';
import ConfirmDialog from '../Common/ConfirmDialog';
const KanbanBoard: React.FC = () => { const KanbanBoard: React.FC = () => {
const { channelId } = useParams<{ channelId: string }>(); const { channelId } = useParams<{ channelId: string }>();
@ -18,8 +21,14 @@ const KanbanBoard: React.FC = () => {
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null); const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [sidebarLoading, setSidebarLoading] = useState(true); const [sidebarLoading, setSidebarLoading] = useState(true);
const [selectedCard, setSelectedCard] = useState<KanbanCard | null>(null); const [selectedCard, setSelectedCard] = useState<KanbanCardExtended | null>(null);
const [showCardModal, setShowCardModal] = useState(false); const [showCardModal, setShowCardModal] = useState(false);
const [showArchiveModal, setShowArchiveModal] = useState(false);
const [archivedCards, setArchivedCards] = useState<any[]>([]);
const [showCreateCardDialog, setShowCreateCardDialog] = useState(false);
const [createCardColumnId, setCreateCardColumnId] = useState<number | null>(null);
const [showDeleteCardDialog, setShowDeleteCardDialog] = useState(false);
const [deleteCardId, setDeleteCardId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
loadSidebarData(); loadSidebarData();
@ -70,13 +79,13 @@ const KanbanBoard: React.FC = () => {
if (error.response?.status === 404) { if (error.response?.status === 404) {
// Board doesn't exist yet, create it with default columns // Board doesn't exist yet, create it with default columns
try { try {
await kanbanAPI.createBoard({ channel_id: parseInt(channelId) }); const newBoard = await kanbanAPI.createBoard({ channel_id: parseInt(channelId) });
// Create default columns // Create default columns using the actual board ID from the response
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done']; const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
for (let i = 0; i < defaultColumns.length; i++) { for (let i = 0; i < defaultColumns.length; i++) {
await kanbanAPI.createColumn({ await kanbanAPI.createColumn({
board_id: parseInt(channelId), // This will be the board ID since we just created it board_id: newBoard.id, // Use the actual board ID from the response
name: defaultColumns[i], name: defaultColumns[i],
position: i position: i
}); });
@ -145,16 +154,20 @@ const KanbanBoard: React.FC = () => {
} }
}; };
const handleCreateCard = async (columnId: number) => { const handleCreateCard = (columnId: number) => {
const cardTitle = prompt('Kartentitel eingeben:'); setCreateCardColumnId(columnId);
if (!cardTitle?.trim()) return; setShowCreateCardDialog(true);
};
const handleConfirmCreateCard = async (cardTitle: string) => {
if (!createCardColumnId) return;
try { try {
const column = board?.columns.find(col => col.id === columnId); const column = board?.columns.find(col => col.id === createCardColumnId);
if (!column) return; if (!column) return;
const newCard = await kanbanAPI.createCard({ const newCard = await kanbanAPI.createCard({
column_id: columnId, column_id: createCardColumnId,
title: cardTitle, title: cardTitle,
position: column.cards.length position: column.cards.length
}); });
@ -162,21 +175,30 @@ const KanbanBoard: React.FC = () => {
setBoard(prev => prev ? { setBoard(prev => prev ? {
...prev, ...prev,
columns: prev.columns.map(col => columns: prev.columns.map(col =>
col.id === columnId col.id === createCardColumnId
? { ...col, cards: [...col.cards, newCard] } ? { ...col, cards: [...col.cards, newCard] }
: col : col
) )
} : null); } : null);
addToast('Karte erstellt', 'success'); addToast('Karte erstellt', 'success');
setShowCreateCardDialog(false);
setCreateCardColumnId(null);
} catch (error) { } catch (error) {
addToast('Fehler beim Erstellen der Karte', 'error'); addToast('Fehler beim Erstellen der Karte', 'error');
} }
}; };
const handleUpdateCard = async (cardId: number, updates: Partial<KanbanCard>) => { const handleUpdateCard = async (cardId: number, updates: Partial<KanbanCardExtended>) => {
try { try {
await kanbanAPI.updateCard(cardId, updates); await kanbanAPI.updateCard(cardId, updates);
// If card is being restored from archive, reload the board to show it
if (updates.is_archived === false) {
setShowCardModal(false);
setSelectedCard(null);
loadBoard();
} else {
setBoard(prev => prev ? { setBoard(prev => prev ? {
...prev, ...prev,
columns: prev.columns.map(col => ({ columns: prev.columns.map(col => ({
@ -186,26 +208,34 @@ const KanbanBoard: React.FC = () => {
) )
})) }))
} : null); } : null);
} catch (error) { }
addToast('Fehler beim Aktualisieren der Karte', 'error'); } catch (error: any) {
addToast('Fehler beim Aktualisieren der Karte: ' + (error.response?.data?.detail || error.message), 'error');
} }
}; };
const handleDeleteCard = async (cardId: number) => { const handleDeleteCard = (cardId: number) => {
if (!confirm('Karte wirklich löschen?')) return; setDeleteCardId(cardId);
setShowDeleteCardDialog(true);
};
const handleConfirmDeleteCard = async () => {
if (!deleteCardId) return;
try { try {
await kanbanAPI.deleteCard(cardId); await kanbanAPI.deleteCard(deleteCardId);
setBoard(prev => prev ? { setBoard(prev => prev ? {
...prev, ...prev,
columns: prev.columns.map(col => ({ columns: prev.columns.map(col => ({
...col, ...col,
cards: col.cards.filter(card => card.id !== cardId) cards: col.cards.filter(card => card.id !== deleteCardId)
})) }))
} : null); } : null);
addToast('Karte gelöscht', 'success'); addToast('Karte in Archiv verschoben', 'success');
setShowDeleteCardDialog(false);
setDeleteCardId(null);
} catch (error) { } catch (error) {
addToast('Fehler beim Löschen der Karte', 'error'); addToast('Fehler beim Archivieren der Karte', 'error');
} }
}; };
@ -251,9 +281,32 @@ const KanbanBoard: React.FC = () => {
} }
}; };
const handleCardClick = (card: KanbanCard) => { const handleCardClick = async (card: KanbanCardExtended) => {
try {
// Load the extended card with all features
const extendedCard = await kanbanAPI.getCardExtended(card.id);
setSelectedCard(extendedCard);
setShowCardModal(true);
} catch (error) {
console.error('Failed to load extended card:', error);
// Fallback to basic card if extended loading fails
setSelectedCard(card); setSelectedCard(card);
setShowCardModal(true); setShowCardModal(true);
}
};
const handleOpenArchiveModal = async () => {
setShowArchiveModal(true);
if (board) {
try {
const archived = await kanbanAPI.getArchivedCards(board.id);
setArchivedCards(archived);
} catch (error) {
console.error('Failed to load archived cards:', error);
addToast('Fehler beim Laden archivierter Karten', 'error');
setArchivedCards([]);
}
}
}; };
if (sidebarLoading) { if (sidebarLoading) {
@ -285,21 +338,33 @@ const KanbanBoard: React.FC = () => {
Kanban-Board nicht gefunden Kanban-Board nicht gefunden
</div> </div>
) : ( ) : (
<div className="flex-1 p-4 overflow-y-auto"> <div className="flex-1 flex flex-col">
<div className="flex items-center justify-between mb-4"> {/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-0.5 flex items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{board.name}
</h1>
{selectedChannel && ( {selectedChannel && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1"> <>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
# {selectedChannel.name} # {selectedChannel.name}
</h2>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0 h-5">
{selectedChannel.description || ''}
</p> </p>
</>
)} )}
</div> </div>
<button
onClick={handleOpenArchiveModal}
className="px-2 py-1 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors whitespace-nowrap"
title="Archiv anzeigen"
>
Archiv
</button>
</div> </div>
<div className="flex gap-4 overflow-x-auto pb-4"> {/* Board Content */}
<div className="flex-1 p-4 overflow-y-auto">
<div className="flex gap-4">
{board.columns.map((column) => ( {board.columns.map((column) => (
<KanbanColumnComponent <KanbanColumnComponent
key={column.id} key={column.id}
@ -314,7 +379,7 @@ const KanbanBoard: React.FC = () => {
))} ))}
</div> </div>
{showCardModal && selectedCard && ( {showCardModal && selectedCard && board && (
<KanbanCardModal <KanbanCardModal
card={selectedCard} card={selectedCard}
onClose={() => { onClose={() => {
@ -325,6 +390,44 @@ const KanbanBoard: React.FC = () => {
/> />
)} )}
</div> </div>
{/* Archive Modal */}
{board && (
<KanbanArchiveModal
isOpen={showArchiveModal}
onClose={() => setShowArchiveModal(false)}
archivedCards={archivedCards}
onSelectCard={(card) => {
setSelectedCard(card as KanbanCardExtended);
setShowCardModal(true);
}}
/>
)}
{/* Create Card Dialog */}
<CreateCardDialog
isOpen={showCreateCardDialog}
onClose={() => {
setShowCreateCardDialog(false);
setCreateCardColumnId(null);
}}
onSubmit={handleConfirmCreateCard}
/>
{/* Delete Card Dialog */}
<ConfirmDialog
isOpen={showDeleteCardDialog}
title="Karte archivieren"
message="Möchten Sie diese Karte wirklich archivieren? Sie können sie später über das Archiv wiederherstellen."
confirmText="Archivieren"
cancelText="Abbrechen"
onConfirm={handleConfirmDeleteCard}
onCancel={() => {
setShowDeleteCardDialog(false);
setDeleteCardId(null);
}}
/>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -1,8 +1,9 @@
import React from 'react'; import React from 'react';
import type { KanbanCard } from '../../types'; import AccountIcon from 'mdi-react/AccountIcon';
import type { KanbanCardExtended } from '../../types';
interface KanbanCardProps { interface KanbanCardProps {
card: KanbanCard; card: KanbanCardExtended;
onClick: () => void; onClick: () => void;
onDelete: (cardId: number) => void; onDelete: (cardId: number) => void;
sourceColumnId: number; sourceColumnId: number;
@ -66,8 +67,8 @@ const KanbanCard: React.FC<KanbanCardProps> = ({
)} )}
{/* Priority and Due Date */} {/* Priority and Due Date */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 flex-wrap">
{card.priority && ( {card.priority && (
<span className={`px-1.5 py-0.5 text-xs rounded border ${getPriorityColor(card.priority)}`}> <span className={`px-1.5 py-0.5 text-xs rounded border ${getPriorityColor(card.priority)}`}>
{card.priority === 'high' ? 'Hoch' : {card.priority === 'high' ? 'Hoch' :
@ -86,26 +87,50 @@ const KanbanCard: React.FC<KanbanCardProps> = ({
)} )}
</div> </div>
{/* Assignee */} {/* Assignee Name with Icon */}
{card.assignee && ( {card.assignee && (
<div className="flex items-center"> <div className="flex items-center gap-1">
{card.assignee.profile_picture ? ( <AccountIcon size="1rem" className="text-gray-400" />
<img <span className="text-xs text-gray-500 dark:text-gray-400">
src={`http://localhost:8000/${card.assignee.profile_picture}`} {card.assignee.full_name || card.assignee.username}
alt={card.assignee.username} </span>
className="w-5 h-5 rounded-full object-cover"
title={card.assignee.username}
/>
) : (
<div
className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold"
title={card.assignee.username}
>
{card.assignee.username.charAt(0).toUpperCase()}
</div> </div>
)} )}
</div> </div>
{/* Feature Badges */}
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-1">
{/* Comments */}
{card.comments_count > 0 && (
<div className="flex items-center gap-1 text-xs text-gray-500">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
{card.comments_count}
</div>
)} )}
{/* Attachments */}
{card.attachments_count > 0 && (
<div className="flex items-center gap-1 text-xs text-gray-500">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
</svg>
{card.attachments_count}
</div>
)}
{/* Checklists */}
{card.checklists_count > 0 && (
<div className="flex items-center gap-1 text-xs text-gray-500">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{card.checklists_count}
</div>
)}
</div>
</div> </div>
{/* Labels */} {/* Labels */}

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { departmentsAPI, kanbanAPI } from '../../services/api'; import { departmentsAPI, kanbanAPI } from '../../services/api';
import ConfirmDialog from '../Common/ConfirmDialog';
import type { KanbanCard, User, Department, KanbanChecklistWithItems } from '../../types'; import type { KanbanCard, User, Department, KanbanChecklistWithItems } from '../../types';
const AddChecklistItemForm: React.FC<{ checklistId: number; onAdd: (checklistId: number, title: string) => void }> = ({ checklistId, onAdd }) => { const AddChecklistItemForm: React.FC<{ checklistId: number; onAdd: (checklistId: number, title: string) => void }> = ({ checklistId, onAdd }) => {
@ -90,6 +91,19 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
const [checklists, setChecklists] = useState<KanbanChecklistWithItems[]>([]); const [checklists, setChecklists] = useState<KanbanChecklistWithItems[]>([]);
const [showChecklistForm, setShowChecklistForm] = useState(false); const [showChecklistForm, setShowChecklistForm] = useState(false);
const [newChecklistTitle, setNewChecklistTitle] = useState(''); const [newChecklistTitle, setNewChecklistTitle] = useState('');
const [activeTab, setActiveTab] = useState<'overview' | 'attachments' | 'comments' | 'activity'>('overview');
const [comments, setComments] = useState<any[]>([]);
const [newComment, setNewComment] = useState('');
const [attachments, setAttachments] = useState<any[]>([]);
const [uploading, setUploading] = useState(false);
const [postingComment, setPostingComment] = useState(false);
const [activity, setActivity] = useState<any[]>([]);
const fileInputRef = React.useRef<HTMLInputElement>(null);
// Confirm Dialog States
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [deleteItemType, setDeleteItemType] = useState<'comment' | 'attachment' | null>(null);
const [deleteItemId, setDeleteItemId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
loadAvailableUsers(); loadAvailableUsers();
@ -105,11 +119,14 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
// Collect all users from these departments // Collect all users from these departments
const userSet = new Map<number, User>(); const userSet = new Map<number, User>();
for (const _dept of departments) { for (const dept of departments) {
// This is a simplified approach - in a real app you'd have an endpoint to get department users try {
// For now, we'll just include the current user and maybe add more logic later const deptUsers: User[] = await departmentsAPI.getUsers(dept.id);
if (user) { for (const deptUser of deptUsers) {
userSet.set(user.id, user); userSet.set(deptUser.id, deptUser);
}
} catch (error) {
console.error(`Failed to load users for department ${dept.id}:`, error);
} }
} }
@ -119,6 +136,21 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
} }
}; };
useEffect(() => {
if (activeTab === 'activity') {
loadActivity();
}
}, [activeTab, card.id]);
const loadActivity = async () => {
try {
const activityData = await kanbanAPI.getCardActivity(card.id);
setActivity(activityData);
} catch (error) {
console.error('Failed to load activity:', error);
}
};
// Auto-save functions for individual fields // Auto-save functions for individual fields
const autoSaveTitle = () => { const autoSaveTitle = () => {
if (title.trim() !== card.title) { if (title.trim() !== card.title) {
@ -133,9 +165,9 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
} }
}; };
const autoSaveAssignee = () => { const autoSavePriority = () => {
if (assigneeId !== card.assignee_id) { if (priority !== (card.priority || 'medium')) {
onUpdate(card.id, { assignee_id: assigneeId }); onUpdate(card.id, { priority });
} }
}; };
@ -146,12 +178,6 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
} }
}; };
const autoSavePriority = () => {
if (priority !== (card.priority || 'medium')) {
onUpdate(card.id, { priority });
}
};
const autoSaveLabels = () => { const autoSaveLabels = () => {
const lbls = labels.trim() || undefined; const lbls = labels.trim() || undefined;
if (lbls !== (card.labels || undefined)) { if (lbls !== (card.labels || undefined)) {
@ -250,22 +276,150 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
useEffect(() => { useEffect(() => {
loadAvailableUsers(); loadAvailableUsers();
loadChecklists(); loadChecklists();
loadAttachments();
loadComments();
}, []); }, []);
const loadComments = async () => {
try {
const data = await kanbanAPI.getCardComments(card.id);
setComments(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Failed to load comments:', error);
setComments([]);
}
};
const handlePostComment = async () => {
if (!newComment.trim()) return;
setPostingComment(true);
try {
const comment = await kanbanAPI.createComment({
card_id: card.id,
content: newComment.trim()
});
setComments(prev => [...prev, comment]);
setNewComment('');
} catch (error) {
console.error('Failed to post comment:', error);
alert('Fehler beim Posten des Kommentars');
} finally {
setPostingComment(false);
}
};
const handleDeleteComment = (commentId: number) => {
setDeleteItemType('comment');
setDeleteItemId(commentId);
setShowConfirmDialog(true);
};
const loadAttachments = async () => {
try {
const data = await kanbanAPI.getCardAttachments(card.id);
setAttachments(Array.isArray(data) ? data : []);
} catch (error) {
console.error('Failed to load attachments:', error);
setAttachments([]);
}
};
const handleUploadAttachment = async (file: File) => {
if (!file) return;
setUploading(true);
try {
const newAttachment = await kanbanAPI.uploadAttachment(card.id, file);
setAttachments(prev => [...prev, newAttachment]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (error) {
console.error('Failed to upload attachment:', error);
alert('Fehler beim Hochladen der Datei');
} finally {
setUploading(false);
}
};
const handleDeleteAttachment = (attachmentId: number) => {
setDeleteItemType('attachment');
setDeleteItemId(attachmentId);
setShowConfirmDialog(true);
};
const handleConfirmDelete = async () => {
if (!deleteItemId || !deleteItemType) return;
try {
if (deleteItemType === 'attachment') {
await kanbanAPI.deleteAttachment(deleteItemId);
setAttachments(prev => prev.filter(a => a.id !== deleteItemId));
} else if (deleteItemType === 'comment') {
await kanbanAPI.deleteComment(deleteItemId);
setComments(prev => prev.filter(c => c.id !== deleteItemId));
}
} catch (error) {
console.error('Failed to delete item:', error);
} finally {
setShowConfirmDialog(false);
setDeleteItemType(null);
setDeleteItemId(null);
}
};
const handleDownloadAttachment = async (attachment: any) => {
try {
const blob = await kanbanAPI.downloadAttachment(attachment.id);
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = attachment.original_filename || attachment.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download attachment:', error);
}
};
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"> className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
onClick={(e) => {
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg shadow-xl w-[900px] mx-4 h-[85vh] overflow-y-auto">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-6 border-b border-gray-200 dark:border-gray-700"> <div className="flex items-center justify-between p-3 border-b border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 gap-2">
<input <input
type="text" type="text"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
onBlur={autoSaveTitle} onBlur={autoSaveTitle}
className="text-xl font-bold text-gray-900 dark:text-white bg-transparent border-b border-transparent hover:border-gray-300 dark:hover:border-gray-600 focus:border-blue-500 focus:outline-none" className="flex-1 text-xl font-bold text-gray-900 dark:text-white bg-transparent border-b border-transparent hover:border-gray-300 dark:hover:border-gray-600 focus:border-blue-500 focus:outline-none resize-none overflow-y-auto max-h-20"
placeholder="Kartentitel eingeben..." placeholder="Kartentitel eingeben..."
style={{ minHeight: '2rem' }}
/> />
<div className="flex items-center gap-2">
{card.is_archived && (
<button
onClick={() => onUpdate(card.id, { is_archived: false })}
className="px-3 py-1 text-sm font-medium text-green-600 dark:text-green-400 hover:bg-green-50 dark:hover:bg-green-900 rounded-lg transition-colors"
title="Karte wiederherstellen"
>
<svg className="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Wiederherstellen
</button>
)}
<button <button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-1" className="text-gray-400 hover:text-gray-600 p-1"
@ -275,11 +429,69 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
</svg> </svg>
</button> </button>
</div> </div>
</div>
{/* Tabs Navigation */}
<div className="flex border-b border-gray-300 dark:border-gray-700 overflow-x-auto bg-white dark:bg-gray-800">
<button
onClick={() => setActiveTab('overview')}
className={`px-4 py-3 font-medium whitespace-nowrap transition-colors ${
activeTab === 'overview'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
Details & Einstellungen
</button>
<button
onClick={() => setActiveTab('attachments')}
className={`px-4 py-3 font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
activeTab === 'attachments'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
Anhänge
{attachments.length > 0 && (
<span className="text-xs bg-gray-300 dark:bg-gray-600 px-1.5 py-0.5 rounded-full">
{attachments.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('comments')}
className={`px-4 py-3 font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
activeTab === 'comments'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
Kommentare
{comments.length > 0 && (
<span className="text-xs bg-gray-300 dark:bg-gray-600 px-1.5 py-0.5 rounded-full">
{comments.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('activity')}
className={`px-4 py-3 font-medium whitespace-nowrap transition-colors ${
activeTab === 'activity'
? 'text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400'
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200'
}`}
>
Aktivität
</button>
</div>
{/* Content */} {/* Content */}
<div className="p-6 space-y-6"> <div className="p-4 bg-gray-50 dark:bg-gray-800">
{/* Overview Tab - Details & Einstellungen */}
{activeTab === 'overview' && (
<div className="space-y-3">
{/* Description */} {/* Description */}
<div> <div className="bg-white dark:bg-gray-700 p-3 rounded-lg border border-gray-200 dark:border-gray-600">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Beschreibung Beschreibung
</label> </label>
@ -287,13 +499,14 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
onBlur={autoSaveDescription} onBlur={autoSaveDescription}
className="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
rows={4} rows={4}
placeholder="Beschreibung hinzufügen..." placeholder="Beschreibung hinzufügen..."
/> />
</div> </div>
{/* Priority */} {/* Priority & Due Date */}
<div className="grid grid-cols-2 gap-4 bg-white dark:bg-gray-700 p-3 rounded-lg border border-gray-200 dark:border-gray-600">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priorität Priorität
@ -304,7 +517,7 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
setPriority(e.target.value as 'low' | 'medium' | 'high'); setPriority(e.target.value as 'low' | 'medium' | 'high');
autoSavePriority(); autoSavePriority();
}} }}
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
> >
<option value="low">Niedrig</option> <option value="low">Niedrig</option>
<option value="medium">Mittel</option> <option value="medium">Mittel</option>
@ -312,7 +525,6 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
</select> </select>
</div> </div>
{/* Due Date */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Fälligkeitsdatum Fälligkeitsdatum
@ -322,11 +534,13 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
value={dueDate} value={dueDate}
onChange={(e) => setDueDate(e.target.value)} onChange={(e) => setDueDate(e.target.value)}
onBlur={autoSaveDueDate} onBlur={autoSaveDueDate}
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/> />
</div> </div>
</div>
{/* Assignee */} {/* Assignee & Labels */}
<div className="grid grid-cols-2 gap-4 bg-white dark:bg-gray-700 p-3 rounded-lg border border-gray-200 dark:border-gray-600">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Zugewiesen an Zugewiesen an
@ -334,10 +548,14 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
<select <select
value={assigneeId || ''} value={assigneeId || ''}
onChange={(e) => { onChange={(e) => {
setAssigneeId(e.target.value ? parseInt(e.target.value) : undefined); const newAssigneeId = e.target.value ? parseInt(e.target.value) : undefined;
autoSaveAssignee(); setAssigneeId(newAssigneeId);
// Check and save immediately with new value
if (newAssigneeId !== card.assignee_id) {
onUpdate(card.id, { assignee_id: newAssigneeId });
}
}} }}
className="p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
> >
<option value="">Nicht zugewiesen</option> <option value="">Nicht zugewiesen</option>
{availableUsers.map((user) => ( {availableUsers.map((user) => (
@ -348,7 +566,6 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
</select> </select>
</div> </div>
{/* Labels */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Labels (kommagetrennt) Labels (kommagetrennt)
@ -358,10 +575,11 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
value={labels} value={labels}
onChange={(e) => setLabels(e.target.value)} onChange={(e) => setLabels(e.target.value)}
onBlur={autoSaveLabels} onBlur={autoSaveLabels}
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white" className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
placeholder="z.B. bug, feature, urgent" placeholder="z.B. bug, feature, urgent"
/> />
</div> </div>
</div>
{/* Checklists */} {/* Checklists */}
<div> <div>
@ -411,7 +629,7 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
{/* Checklists */} {/* Checklists */}
<div className="space-y-4"> <div className="space-y-4">
{checklists.map((checklist) => ( {checklists.map((checklist) => (
<div key={checklist.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4"> <div key={checklist.id} className="border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-gray-700">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900 dark:text-white">{checklist.title}</h4> <h4 className="font-medium text-gray-900 dark:text-white">{checklist.title}</h4>
<button <button
@ -468,9 +686,209 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
)} )}
</div> </div>
</div> </div>
</div>
)}
{/* Attachments Tab */}
{activeTab === 'attachments' && (
<div className="space-y-2">
{/* Upload Area */}
<div
className="border-2 border-dashed border-gray-400 dark:border-gray-600 rounded-lg p-4 text-center hover:border-blue-500 dark:hover:border-blue-400 transition-colors cursor-pointer bg-white dark:bg-gray-700"
onClick={() => fileInputRef.current?.click()}
onDrop={(e) => {
e.preventDefault();
const files = e.dataTransfer.files;
if (files.length > 0) {
handleUploadAttachment(files[0]);
}
}}
onDragOver={(e) => {
e.preventDefault();
}}
>
<input
ref={fileInputRef}
type="file"
onChange={(e) => {
if (e.target.files && e.target.files.length > 0) {
handleUploadAttachment(e.target.files[0]);
}
}}
className="hidden"
disabled={uploading}
/>
<svg className="w-8 h-8 mx-auto mb-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium mb-1">
{uploading ? 'Wird hochgeladen...' : 'Datei hochladen'}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{uploading ? 'Bitte warten...' : 'Klicken oder ziehen'}
</p>
</div>
{/* Files List */}
<div className="space-y-2">
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
Hochgeladene Dateien ({attachments.length})
</h3>
{attachments.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Noch keine Anhänge vorhanden
</div>
) : (
<div className="space-y-2">
{attachments.map((attachment: any) => (
<div key={attachment.id} className="flex items-center justify-between p-3 bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg">
<div className="flex items-center gap-2 flex-1 min-w-0">
<svg className="w-5 h-5 text-gray-400 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
</svg>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{attachment.filename || attachment.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{attachment.created_at ? new Date(attachment.created_at).toLocaleDateString('de-DE') : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleDownloadAttachment(attachment)}
className="text-gray-400 hover:text-blue-500 p-2"
title="Anhang herunterladen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
<button
onClick={() => handleDeleteAttachment(attachment.id)}
className="text-gray-400 hover:text-red-500 p-2"
title="Anhang löschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Kommentare Tab */}
{activeTab === 'comments' && (
<div className="space-y-2">
{/* Add Comment Form */}
<div className="border border-gray-300 dark:border-gray-600 rounded-lg p-4 bg-white dark:bg-gray-700">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Schreiben Sie einen Kommentar..."
className="w-full p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm resize-none"
rows={3}
disabled={postingComment}
/>
<div className="flex gap-2 mt-2">
<button
onClick={handlePostComment}
disabled={postingComment || !newComment.trim()}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{postingComment ? 'Wird gepostet...' : 'Kommentar hinzufügen'}
</button>
</div>
</div>
{/* Comments List */}
<div className="space-y-3">
{comments.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Noch keine Kommentare vorhanden. Schreiben Sie den ersten Kommentar!
</div>
) : (
comments.map((comment: any) => (
<div key={comment.id} className="border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-700">
<div className="flex items-start justify-between mb-2">
<div>
<div className="font-medium text-sm text-gray-900 dark:text-white">
{comment.user?.username || comment.user?.full_name || 'Unbekannter Nutzer'}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">
{comment.created_at ? new Date(comment.created_at).toLocaleString('de-DE') : ''}
</div>
</div>
{user?.username === comment.user?.username && (
<button
onClick={() => handleDeleteComment(comment.id)}
className="text-gray-400 hover:text-red-500 p-1"
title="Kommentar löschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</button>
)}
</div>
<p className="text-sm text-gray-700 dark:text-gray-300 break-words">
{comment.content}
</p>
</div>
))
)}
</div>
</div>
)}
{/* Aktivität Tab */}
{activeTab === 'activity' && (
<div className="space-y-3">
{activity.length === 0 ? (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Keine Aktivität vorhanden
</div>
) : (
<div className="space-y-3">
{activity.map((log, index) => (
<div key={index} className="flex gap-3 pb-3 border-b border-gray-300 dark:border-gray-700 last:border-b-0 bg-white dark:bg-gray-700 p-3 rounded-lg">
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
<svg className="w-4 h-4 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white capitalize">
{log.action === 'updated' && `${log.field_name} aktualisiert`}
{log.action === 'moved' && `Zu ${log.new_value} verschoben`}
{log.action === 'created' && 'Karte erstellt'}
{log.action === 'archived' && 'Karte archiviert'}
{!['updated', 'moved', 'created', 'archived'].includes(log.action) && log.action}
</p>
{log.action === 'updated' && log.old_value && log.new_value && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Von <span className="font-semibold">{log.old_value}</span> zu <span className="font-semibold">{log.new_value}</span>
</p>
)}
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{new Date(log.created_at).toLocaleString('de-DE')}
</p>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Metadata */} {/* Metadata */}
<div className="text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-4"> <div className="text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-4 mt-6">
Erstellt: {new Date(card.created_at).toLocaleString('de-DE')} Erstellt: {new Date(card.created_at).toLocaleString('de-DE')}
{card.updated_at !== card.created_at && ( {card.updated_at !== card.created_at && (
<span className="ml-4"> <span className="ml-4">
@ -480,6 +898,21 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
</div> </div>
</div> </div>
</div> </div>
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={showConfirmDialog}
title={deleteItemType === 'attachment' ? 'Anhang löschen' : 'Kommentar löschen'}
message={deleteItemType === 'attachment' ? 'Möchten Sie diese Datei wirklich löschen?' : 'Möchten Sie diesen Kommentar wirklich löschen?'}
confirmText="Löschen"
cancelText="Abbrechen"
onConfirm={handleConfirmDelete}
onCancel={() => {
setShowConfirmDialog(false);
setDeleteItemType(null);
setDeleteItemId(null);
}}
/>
</div> </div>
); );
}; };

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import type { KanbanColumnWithCards, KanbanCard } from '../../types'; import type { KanbanColumnWithCards, KanbanCardExtended } from '../../types';
import KanbanCardComponent from './KanbanCard'; import KanbanCardComponent from './KanbanCard';
interface KanbanColumnProps { interface KanbanColumnProps {
@ -9,7 +9,7 @@ interface KanbanColumnProps {
onCreateCard: (columnId: number) => void; onCreateCard: (columnId: number) => void;
onDeleteCard: (cardId: number) => void; onDeleteCard: (cardId: number) => void;
onMoveCard: (cardId: number, targetColumnId: number, newPosition: number) => void; onMoveCard: (cardId: number, targetColumnId: number, newPosition: number) => void;
onCardClick: (card: KanbanCard) => void; onCardClick: (card: KanbanCardExtended) => void;
} }
const KanbanColumn: React.FC<KanbanColumnProps> = ({ const KanbanColumn: React.FC<KanbanColumnProps> = ({
@ -87,8 +87,8 @@ const KanbanColumn: React.FC<KanbanColumnProps> = ({
return ( return (
<div <div
className={`flex-shrink-0 w-72 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md p-3 ${ className={`flex-1 min-w-72 bg-white/70 dark:bg-gray-800/50 border border-gray-200/50 dark:border-gray-700/50 rounded-md p-3 ${
draggedOver ? 'ring-1 ring-blue-500 bg-blue-50 dark:bg-blue-900/10' : '' draggedOver ? 'ring-1 ring-blue-500 bg-blue-50/50 dark:bg-blue-900/20' : ''
}`} }`}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
@ -133,12 +133,22 @@ const KanbanColumn: React.FC<KanbanColumnProps> = ({
{column.cards.length} {column.cards.length}
</span> </span>
</div> </div>
<div className="flex items-center gap-1">
<button
onClick={() => onCreateCard(column.id)}
className="text-gray-400 hover:text-green-500 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
title="Neue Karte hinzufügen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
{(() => { {(() => {
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done']; const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
return !defaultColumns.includes(column.name) && ( return !defaultColumns.includes(column.name) && (
<button <button
onClick={() => onDeleteColumn(column.id)} onClick={() => onDeleteColumn(column.id)}
className="text-gray-400 hover:text-red-500 p-1" className="text-gray-400 hover:text-red-500 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
title="Spalte löschen" title="Spalte löschen"
> >
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -148,6 +158,7 @@ const KanbanColumn: React.FC<KanbanColumnProps> = ({
); );
})()} })()}
</div> </div>
</div>
{/* Cards */} {/* Cards */}
<div className="space-y-2 min-h-[150px]"> <div className="space-y-2 min-h-[150px]">

View File

@ -6,6 +6,7 @@ interface KanbanSidebarProps {
departments: Department[]; departments: Department[];
selectedChannel: Channel | null; selectedChannel: Channel | null;
onSelectChannel: (channel: Channel) => void; onSelectChannel: (channel: Channel) => void;
onDeleteChannel?: (channel: Channel) => void;
} }
const KanbanSidebar: React.FC<KanbanSidebarProps> = ({ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
@ -13,7 +14,9 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
departments, departments,
selectedChannel, selectedChannel,
onSelectChannel, onSelectChannel,
onDeleteChannel,
}) => { }) => {
// Group channels by department // Group channels by department
const channelsByDept = channels.reduce((acc, channel) => { const channelsByDept = channels.reduce((acc, channel) => {
if (!acc[channel.department_id]) { if (!acc[channel.department_id]) {
@ -23,22 +26,31 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
return acc; return acc;
}, {} as Record<number, Channel[]>); }, {} as Record<number, Channel[]>);
const handleDeleteClick = (channel: Channel, e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteChannel) {
if (window.confirm(`Channel "${channel.name}" und alle seine Inhalte (Nachrichten, Kanban-Board) wirklich löschen? Diese Aktion kann nicht rückgängig gemacht werden.`)) {
onDeleteChannel(channel);
}
}
};
return ( return (
<div className="w-52 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col"> <div className="w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 flex flex-col">
<div className="px-3 py-2 border-b border-gray-200 dark:border-gray-700"> <div className="px-3 py-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Kanban Boards</h3> <h3 className="font-semibold text-base text-gray-900 dark:text-white">Kanban Boards</h3>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{departments.map((dept) => ( {departments.map((dept) => (
<div key={dept.id} className="mb-3"> <div key={dept.id} className="mb-3">
<div className="px-3 py-1.5 text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide"> <div className="px-3 py-1.5 text-sm font-semibold text-gray-600 dark:text-gray-400">
{dept.name} {dept.name}
</div> </div>
{channelsByDept[dept.id]?.map((channel) => ( {channelsByDept[dept.id]?.map((channel) => (
<div key={channel.id} className="relative group">
<button <button
key={channel.id}
onClick={() => onSelectChannel(channel)} onClick={() => onSelectChannel(channel)}
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${ className={`w-full text-left px-3 py-1.5 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
selectedChannel?.id === channel.id selectedChannel?.id === channel.id
@ -47,7 +59,7 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
}`} }`}
> >
<div className="flex items-center"> <div className="flex items-center">
<span className="text-gray-500 dark:text-gray-400 mr-2">#</span> <span className="text-gray-500 dark:text-gray-400 mr-1">#</span>
<span className="truncate">{channel.name}</span> <span className="truncate">{channel.name}</span>
</div> </div>
{channel.description && ( {channel.description && (
@ -56,6 +68,19 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
</div> </div>
)} )}
</button> </button>
{onDeleteChannel && (
<button
onClick={(e) => handleDeleteClick(channel, e)}
className="absolute right-1 top-1 opacity-0 group-hover:opacity-100 p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-opacity"
title="Channel löschen"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</button>
)}
</div>
))} ))}
</div> </div>
))} ))}

View File

@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { departmentsAPI } from '../../services/api'; import { useAuth } from '../../contexts/AuthContext';
import { departmentsAPI, getApiUrl } from '../../services/api';
import type { Department } from '../../types'; import type { Department } from '../../types';
import { isAdmin } from '../../types';
import ToastContainer from '../ToastContainer'; import ToastContainer from '../ToastContainer';
const Layout: React.FC = () => { const Layout: React.FC = () => {
@ -49,7 +50,7 @@ const Layout: React.FC = () => {
return; return;
} }
if (user.is_admin) { if (isAdmin(user)) {
if (isMounted) { if (isMounted) {
setHasSnippetAccess(true); setHasSnippetAccess(true);
} }
@ -76,17 +77,22 @@ const Layout: React.FC = () => {
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [user?.id, user?.is_admin]); }, [user?.id, isAdmin(user)]);
return ( return (
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900"> <div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
{/* Header */} {/* Header */}
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-2"> <header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-3 relative">
<div className="flex items-center justify-between"> <div className="flex items-center">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-16">
<div className="flex items-center space-x-2">
<h1 className="text-base font-bold text-gray-900 dark:text-white"> <h1 className="text-base font-bold text-gray-900 dark:text-white">
Collabrix Collabrix
</h1> </h1>
<span className="text-xs text-gray-500 dark:text-gray-400 mt-1 font-inter">
Struktur für Teams
</span>
</div>
<nav className="flex space-x-1.5"> <nav className="flex space-x-1.5">
<Link <Link
to="/" to="/"
@ -98,7 +104,17 @@ const Layout: React.FC = () => {
> >
Chat Chat
</Link> </Link>
{(user?.is_admin || hasSnippetAccess) && ( <Link
to="/kanban"
className={`px-3 py-1.5 text-sm rounded ${
location.pathname.startsWith('/kanban')
? 'bg-blue-500 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Kanban
</Link>
{(isAdmin(user) || hasSnippetAccess) && (
<Link <Link
to="/snippets" to="/snippets"
className={`px-3 py-1.5 text-sm rounded ${ className={`px-3 py-1.5 text-sm rounded ${
@ -110,32 +126,10 @@ const Layout: React.FC = () => {
Snippets Snippets
</Link> </Link>
)} )}
<Link
to="/kanban"
className={`px-3 py-1.5 text-sm rounded ${
location.pathname.startsWith('/kanban')
? 'bg-blue-500 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Kanban
</Link>
{user?.is_admin && (
<Link
to="/admin"
className={`px-3 py-1.5 text-sm rounded ${
location.pathname === '/admin'
? 'bg-blue-500 text-white'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
Admin
</Link>
)}
</nav> </nav>
</div> </div>
<div className="flex items-center space-x-3"> <div className="absolute right-3 top-2 flex items-center space-x-3">
<ToastContainer /> <ToastContainer />
<div className="flex items-center space-x-2.5 relative user-menu-container"> <div className="flex items-center space-x-2.5 relative user-menu-container">
<button <button
@ -144,7 +138,7 @@ const Layout: React.FC = () => {
> >
{user?.profile_picture ? ( {user?.profile_picture ? (
<img <img
src={`http://localhost:8000/${user.profile_picture}`} src={getApiUrl(user.profile_picture)}
alt={user.username} alt={user.username}
className="w-8 h-8 rounded-full object-cover" className="w-8 h-8 rounded-full object-cover"
/> />
@ -154,7 +148,7 @@ const Layout: React.FC = () => {
</div> </div>
)} )}
<span className="text-sm font-medium text-gray-900 dark:text-white"> <span className="text-sm font-medium text-gray-900 dark:text-white">
{user?.username} {user?.full_name || user?.username}
</span> </span>
<svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@ -170,6 +164,15 @@ const Layout: React.FC = () => {
> >
Profil Profil
</Link> </Link>
{isAdmin(user) && (
<Link
to="/admin"
onClick={() => setUserMenuOpen(false)}
className="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
Administration
</Link>
)}
<button <button
onClick={() => { onClick={() => {
toggleTheme(); toggleTheme();

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext'; import { useAuth } from '../../contexts/AuthContext';
import { authAPI, departmentsAPI } from '../../services/api'; import { authAPI, departmentsAPI, getApiUrl } from '../../services/api';
import type { Department } from '../../types'; import type { Department } from '../../types';
const ProfilePage: React.FC = () => { const ProfilePage: React.FC = () => {
@ -147,14 +147,14 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
{profilePicture ? ( {profilePicture ? (
<img <img
src={`http://localhost:8000/${profilePicture}`} src={getApiUrl(profilePicture)}
alt="Profile" alt="Profile"
className="w-32 h-32 rounded-full object-cover mb-4" className="w-32 h-32 rounded-full object-cover mb-4"
onError={(e) => { onError={(e) => {
console.error('Failed to load image:', `http://localhost:8000/${profilePicture}`); console.error('Failed to load image:', getApiUrl(profilePicture));
e.currentTarget.style.display = 'none'; e.currentTarget.style.display = 'none';
}} }}
onLoad={() => console.log('Image loaded successfully:', `http://localhost:8000/${profilePicture}`)} onLoad={() => console.log('Image loaded successfully:', getApiUrl(profilePicture))}
/> />
) : ( ) : (
<div className="w-32 h-32 bg-blue-500 rounded-full flex items-center justify-center text-white text-4xl font-bold mb-4"> <div className="w-32 h-32 bg-blue-500 rounded-full flex items-center justify-center text-white text-4xl font-bold mb-4">
@ -177,7 +177,7 @@ const ProfilePage: React.FC = () => {
{user.username} {user.username}
</h2> </h2>
<p className="text-sm text-gray-500 dark:text-gray-400"> <p className="text-sm text-gray-500 dark:text-gray-400">
{user.is_admin ? 'Administrator' : 'Benutzer'} {user.role === 'superadmin' ? 'SuperAdministrator' : user.role === 'admin' ? 'Administrator' : 'Benutzer'}
</p> </p>
</div> </div>

View File

@ -38,7 +38,7 @@ const LANGUAGES = [
interface SnippetEditorProps { interface SnippetEditorProps {
snippet: Snippet | null; snippet: Snippet | null;
onSave: () => void; onSave: (createdSnippet?: Snippet) => void;
onCancel: () => void; onCancel: () => void;
} }
@ -98,13 +98,14 @@ const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel
department_id: visibility === 'department' ? departmentId : undefined, department_id: visibility === 'department' ? departmentId : undefined,
}; };
let result;
if (snippet) { if (snippet) {
await snippetsAPI.update(snippet.id, data); result = await snippetsAPI.update(snippet.id, data);
} else { } else {
await snippetsAPI.create(data); result = await snippetsAPI.create(data);
} }
onSave(); onSave(result);
} catch (error: any) { } catch (error: any) {
console.error('Failed to save snippet:', error); console.error('Failed to save snippet:', error);
addToast(error.response?.data?.detail || 'Failed to save snippet', 'error'); addToast(error.response?.data?.detail || 'Failed to save snippet', 'error');

View File

@ -69,10 +69,17 @@ const SnippetLibrary: React.FC = () => {
setShowEditor(true); setShowEditor(true);
}; };
const handleSave = async () => { const handleSave = async (createdSnippet?: Snippet) => {
setShowEditor(false); setShowEditor(false);
setEditingSnippet(null); setEditingSnippet(null);
if (createdSnippet) {
// Add the new snippet to the existing list
setSnippets(prev => [createdSnippet, ...prev]);
} else {
// For updates, reload all snippets
await loadSnippets(); await loadSnippets();
}
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {

View File

@ -0,0 +1,33 @@
import React from 'react';
import { MarkChatUnread } from '@mui/icons-material';
interface BlinkingEnvelopeProps {
hasNewMessages: boolean;
className?: string;
}
const BlinkingEnvelope: React.FC<BlinkingEnvelopeProps> = ({ hasNewMessages, className = '' }) => {
if (!hasNewMessages) return null;
const blinkingStyle = `
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0.3; }
}
.blinking-envelope {
animation: blink 1s infinite;
}
`;
return (
<>
<style>{blinkingStyle}</style>
<MarkChatUnread
className={`text-gray-900 dark:text-white blinking-envelope ${className}`}
style={{ fontSize: '18px' }}
/>
</>
);
};
export default BlinkingEnvelope;

View File

@ -0,0 +1,21 @@
import React from 'react';
import { useUserStatus } from '../../contexts/UserStatusContext';
import UserStatusIndicator from './UserStatusIndicator';
const HeaderStatusIndicator: React.FC = () => {
const { userStatuses } = useUserStatus();
const onlineCount = userStatuses.filter(user => user.status === 'online').length;
const totalCount = userStatuses.length;
return (
<div className="flex items-center space-x-2 px-3 py-1 bg-gray-50 dark:bg-gray-700 rounded-lg border border-gray-200 dark:border-gray-600">
<UserStatusIndicator status="online" size="sm" />
<span className="text-sm text-gray-700 dark:text-gray-300">
{onlineCount}/{totalCount} online
</span>
</div>
);
};
export default HeaderStatusIndicator;

View File

@ -0,0 +1,35 @@
import React from 'react';
import { UserStatus } from '../../contexts/UserStatusContext';
interface UserStatusIndicatorProps {
status: UserStatus;
size?: 'sm' | 'md' | 'lg';
className?: string;
}
const UserStatusIndicator: React.FC<UserStatusIndicatorProps> = ({
status,
size = 'sm',
className = ''
}) => {
const sizeClasses = {
sm: 'w-2 h-2',
md: 'w-3 h-3',
lg: 'w-4 h-4'
};
const statusClasses: Record<UserStatus, string> = {
online: 'bg-green-500',
away: 'bg-yellow-500',
offline: 'bg-red-500'
};
return (
<div
className={`${sizeClasses[size]} ${statusClasses[status]} rounded-full border border-white dark:border-gray-800 ${className}`}
title={`Status: ${status}`}
/>
);
};
export default UserStatusIndicator;

View File

@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { authAPI } from '../services/api'; import { authAPI } from '../services/api';
import { useToast } from './ToastContext';
import type { User, LoginRequest, RegisterRequest } from '../types'; import type { User, LoginRequest, RegisterRequest } from '../types';
interface AuthContextType { interface AuthContextType {
@ -10,6 +11,7 @@ interface AuthContextType {
logout: () => void; logout: () => void;
refreshUser: () => Promise<void>; refreshUser: () => Promise<void>;
isAuthenticated: boolean; isAuthenticated: boolean;
presenceWs: WebSocket | null;
} }
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -17,13 +19,35 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('token')); const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [presenceWs, setPresenceWs] = useState<WebSocket | null>(null);
const { addToast } = useToast();
useEffect(() => { useEffect(() => {
if (token) { if (token && window.location.pathname !== '/login' && window.location.pathname !== '/register') {
loadUser(); loadUser();
} else if (!token && window.location.pathname !== '/login' && window.location.pathname !== '/register') {
// Redirect to login if no token and not on auth pages
window.location.href = '/login';
} }
}, [token]); }, [token]);
// Listen for session expired events
useEffect(() => {
const handleSessionExpired = () => {
addToast('Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.', 'warning', 5000);
setToken(null);
setUser(null);
closePresenceConnection();
// Redirect to login after a short delay to show the toast
setTimeout(() => {
window.location.href = '/login';
}, 1000);
};
window.addEventListener('sessionExpired', handleSessionExpired);
return () => window.removeEventListener('sessionExpired', handleSessionExpired);
}, [addToast]);
const loadUser = async () => { const loadUser = async () => {
try { try {
const userData = await authAPI.getCurrentUser(); const userData = await authAPI.getCurrentUser();
@ -35,9 +59,64 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
root.classList.remove('light', 'dark'); root.classList.remove('light', 'dark');
root.classList.add(userData.theme); root.classList.add(userData.theme);
} }
// Open presence connection when user is loaded
openPresenceConnection();
} catch (error) { } catch (error) {
// If loading user fails, clear token and user
console.error('Failed to load user:', error); console.error('Failed to load user:', error);
logout(); localStorage.removeItem('token');
setToken(null);
setUser(null);
closePresenceConnection();
// Show toast and redirect to login
addToast('Sitzung konnte nicht wiederhergestellt werden. Bitte melden Sie sich erneut an.', 'error', 5000);
setTimeout(() => {
window.location.href = '/login';
}, 1000);
}
};
const openPresenceConnection = () => {
if (token && !presenceWs) {
// Use API URL to determine WebSocket host
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const url = new URL(apiUrl);
const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${url.host}/api/ws/0?token=${token}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('Presence WebSocket connected');
};
ws.onclose = () => {
console.log('Presence WebSocket disconnected');
setPresenceWs(null);
};
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'message' || data.type === 'direct_message') {
// Dispatch custom event for unread messages
window.dispatchEvent(new CustomEvent('unreadMessage', { detail: data }));
} else if (data.type === 'read_marker') {
// Dispatch read marker events for syncing last-seen across sessions
window.dispatchEvent(new CustomEvent('readMarker', { detail: data }));
}
} catch (error) {
console.error('Error parsing presence WebSocket message:', error);
}
};
setPresenceWs(ws);
}
};
const closePresenceConnection = () => {
if (presenceWs) {
presenceWs.close();
setPresenceWs(null);
} }
}; };
@ -45,6 +124,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const response = await authAPI.login(data); const response = await authAPI.login(data);
localStorage.setItem('token', response.access_token); localStorage.setItem('token', response.access_token);
setToken(response.access_token); setToken(response.access_token);
// Load user immediately after login
await loadUser();
}; };
const register = async (data: RegisterRequest) => { const register = async (data: RegisterRequest) => {
@ -54,6 +135,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}; };
const logout = () => { const logout = () => {
// Close presence connection before logging out
closePresenceConnection();
localStorage.removeItem('token'); localStorage.removeItem('token');
localStorage.removeItem('lastVisitedPath'); localStorage.removeItem('lastVisitedPath');
sessionStorage.removeItem('routeRestored'); sessionStorage.removeItem('routeRestored');
@ -80,6 +163,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
logout, logout,
refreshUser, refreshUser,
isAuthenticated: !!token && !!user, isAuthenticated: !!token && !!user,
presenceWs,
}} }}
> >
{children} {children}

View File

@ -0,0 +1,192 @@
import React, { createContext, useContext, useState, ReactNode, useEffect } from 'react';
import { useAuth } from './AuthContext';
import { lastSeenAPI } from '../services/api';
interface UnreadMessagesContextType {
unreadChannels: Set<number>;
unreadDirectMessages: Set<number>;
activeChannelId: number | null;
activeDirectMessageUserId: number | null;
markChannelAsRead: (channelId: number) => void;
markChannelAsUnread: (channelId: number) => void;
markDirectMessageAsRead: (userId: number) => void;
markDirectMessageAsUnread: (userId: number) => void;
hasUnreadChannel: (channelId: number) => boolean;
hasUnreadDirectMessage: (userId: number) => boolean;
setActiveChannel: (channelId: number | null) => void;
setActiveDirectMessage: (userId: number | null) => void;
}
const UnreadMessagesContext = createContext<UnreadMessagesContextType | undefined>(undefined);
export const useUnreadMessages = () => {
const context = useContext(UnreadMessagesContext);
if (context === undefined) {
throw new Error('useUnreadMessages must be used within a UnreadMessagesProvider');
}
return context;
};
interface UnreadMessagesProviderProps {
children: ReactNode;
}
export const UnreadMessagesProvider: React.FC<UnreadMessagesProviderProps> = ({ children }) => {
const [unreadChannels, setUnreadChannels] = useState<Set<number>>(new Set());
const [unreadDirectMessages, setUnreadDirectMessages] = useState<Set<number>>(new Set());
const [activeChannelId, setActiveChannelId] = useState<number | null>(null);
const [activeDirectMessageUserId, setActiveDirectMessageUserId] = useState<number | null>(null);
const { user } = useAuth();
// Listen for unread message events from the presence WebSocket
useEffect(() => {
const handleUnreadMessage = (event: CustomEvent) => {
const data = event.detail;
if (data.type === 'message' && data.message) {
// Mark channel as unread if message is from another user and channel is not active
if (user && data.message.sender_id !== user.id && activeChannelId !== data.message.channel_id) {
markChannelAsUnread(data.message.channel_id);
}
} else if (data.type === 'direct_message' && data.message) {
// Mark DM as unread if message is from another user and DM is not active
if (user && data.message.sender_id !== user.id && activeDirectMessageUserId !== data.message.sender_id) {
markDirectMessageAsUnread(data.message.sender_id);
}
}
};
window.addEventListener('unreadMessage', handleUnreadMessage as EventListener);
return () => {
window.removeEventListener('unreadMessage', handleUnreadMessage as EventListener);
};
}, [user, activeChannelId, activeDirectMessageUserId]);
// Listen for read_marker events to sync last-seen across sessions (and own sessions)
useEffect(() => {
const handleReadMarker = (event: CustomEvent) => {
const data = event.detail;
try {
if (!data) return;
// If it's a channel read marker (from any user, including own)
if (data.channel_id && data.user_id) {
try {
localStorage.setItem(`channel_last_seen_${data.channel_id}`, data.last_seen);
// Dispatch a custom event so MessageList knows to recompute firstUnreadIndex
window.dispatchEvent(new CustomEvent('lastSeenUpdated', {
detail: { channel_id: data.channel_id }
}));
} catch (e) {}
// Remove unread flag for that channel if present
setUnreadChannels(prev => {
const copy = new Set(prev);
if (copy.has(data.channel_id)) copy.delete(data.channel_id);
return copy;
});
}
// If it's a DM read marker (from any user, including own)
if (data.dm_user_id && data.user_id) {
try {
localStorage.setItem(`dm_last_seen_${data.user_id}`, data.last_seen);
// Dispatch a custom event so DirectMessageView knows to recompute
window.dispatchEvent(new CustomEvent('lastSeenUpdated', {
detail: { dm_user_id: data.user_id }
}));
} catch (e) {}
setUnreadDirectMessages(prev => {
const copy = new Set(prev);
if (copy.has(data.user_id)) copy.delete(data.user_id);
return copy;
});
}
} catch (e) {
// ignore
}
};
window.addEventListener('readMarker', handleReadMarker as EventListener);
return () => window.removeEventListener('readMarker', handleReadMarker as EventListener);
}, [user]);
const markChannelAsRead = (channelId: number) => {
setUnreadChannels(prev => {
const newSet = new Set(prev);
newSet.delete(channelId);
return newSet;
});
try {
const iso = new Date().toISOString();
// best-effort: persist to server, fallback to localStorage
lastSeenAPI.setLastSeen({ channel_id: channelId, last_seen: iso }).catch(() => {
localStorage.setItem(`channel_last_seen_${channelId}`, iso);
});
// also keep local copy immediately
localStorage.setItem(`channel_last_seen_${channelId}`, iso);
} catch (e) {
// ignore storage errors
}
};
const markChannelAsUnread = (channelId: number) => {
setUnreadChannels(prev => new Set(prev).add(channelId));
};
const markDirectMessageAsRead = (userId: number) => {
setUnreadDirectMessages(prev => {
const newSet = new Set(prev);
newSet.delete(userId);
return newSet;
});
try {
const iso = new Date().toISOString();
lastSeenAPI.setLastSeen({ dm_user_id: userId, last_seen: iso }).catch(() => {
localStorage.setItem(`dm_last_seen_${userId}`, iso);
});
localStorage.setItem(`dm_last_seen_${userId}`, iso);
} catch (e) {
// ignore
}
};
const markDirectMessageAsUnread = (userId: number) => {
setUnreadDirectMessages(prev => new Set(prev).add(userId));
};
const setActiveChannel = (channelId: number | null) => {
setActiveChannelId(channelId);
};
const setActiveDirectMessage = (userId: number | null) => {
setActiveDirectMessageUserId(userId);
};
const hasUnreadChannel = (channelId: number) => {
return unreadChannels.has(channelId);
};
const hasUnreadDirectMessage = (userId: number) => {
return unreadDirectMessages.has(userId);
};
return (
<UnreadMessagesContext.Provider
value={{
unreadChannels,
unreadDirectMessages,
activeChannelId,
activeDirectMessageUserId,
markChannelAsRead,
markChannelAsUnread,
markDirectMessageAsRead,
markDirectMessageAsUnread,
hasUnreadChannel,
hasUnreadDirectMessage,
setActiveChannel,
setActiveDirectMessage,
}}
>
{children}
</UnreadMessagesContext.Provider>
);
};

View File

@ -0,0 +1,111 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { userStatusAPI } from '../services/api';
import { useAuth } from './AuthContext';
export type UserStatus = 'online' | 'away' | 'offline';
interface UserStatusInfo {
user_id: number;
username: string;
full_name: string;
status: UserStatus;
}
interface UserStatusContextType {
userStatuses: UserStatusInfo[];
getUserStatus: (userId: number) => UserStatus;
refreshStatuses: () => Promise<void>;
}
const UserStatusContext = createContext<UserStatusContextType | undefined>(undefined);
export const useUserStatus = () => {
const context = useContext(UserStatusContext);
if (context === undefined) {
throw new Error('useUserStatus must be used within a UserStatusProvider');
}
return context;
};
interface UserStatusProviderProps {
children: ReactNode;
}
export const UserStatusProvider: React.FC<UserStatusProviderProps> = ({ children }) => {
const [userStatuses, setUserStatuses] = useState<UserStatusInfo[]>([]);
const { token, presenceWs } = useAuth();
const refreshStatuses = async () => {
try {
const statuses = await userStatusAPI.getUserStatuses();
setUserStatuses(statuses.map(s => ({ ...s, status: s.status as UserStatus })));
} catch (error) {
console.error('Failed to load user statuses:', error);
}
};
const getUserStatus = (userId: number): UserStatus => {
const userStatus = userStatuses.find(u => u.user_id === userId);
return userStatus?.status || 'offline';
};
// Handle real-time status updates via WebSocket
useEffect(() => {
if (!presenceWs) return;
const handleMessage = (event: MessageEvent) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'user_status_update') {
setUserStatuses(prevStatuses => {
const updatedStatuses = prevStatuses.map(status =>
status.user_id === data.user_id
? { ...status, status: data.status as UserStatus }
: status
);
// If user not in list, add them (shouldn't happen but safety check)
const existingUser = updatedStatuses.find(s => s.user_id === data.user_id);
if (!existingUser) {
// We need to fetch user info for new users
refreshStatuses();
return prevStatuses;
}
return updatedStatuses;
});
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
presenceWs.addEventListener('message', handleMessage);
return () => {
presenceWs.removeEventListener('message', handleMessage);
};
}, [presenceWs]);
useEffect(() => {
// Initial load
if (token) {
refreshStatuses();
}
// Refresh every 30 seconds as fallback
const interval = setInterval(() => {
if (token) {
refreshStatuses();
}
}, 30000);
return () => clearInterval(interval);
}, [token]);
return (
<UserStatusContext.Provider value={{ userStatuses, getUserStatus, refreshStatuses }}>
{children}
</UserStatusContext.Provider>
);
};

View File

@ -5,15 +5,21 @@ import './index.css';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { ThemeProvider } from './contexts/ThemeContext'; import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext'; import { ToastProvider } from './contexts/ToastContext';
import { UserStatusProvider } from './contexts/UserStatusContext';
import { UnreadMessagesProvider } from './contexts/UnreadMessagesContext';
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<ThemeProvider> <ThemeProvider>
<AuthProvider>
<ToastProvider> <ToastProvider>
<AuthProvider>
<UserStatusProvider>
<UnreadMessagesProvider>
<App /> <App />
</ToastProvider> </UnreadMessagesProvider>
</UserStatusProvider>
</AuthProvider> </AuthProvider>
</ToastProvider>
</ThemeProvider> </ThemeProvider>
</StrictMode> </StrictMode>
); );

View File

@ -24,6 +24,22 @@ api.interceptors.request.use((config) => {
return config; return config;
}); });
// Handle 401 responses (token expired/invalid)
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token is invalid or expired, logout user
localStorage.removeItem('token');
localStorage.removeItem('lastVisitedPath');
sessionStorage.removeItem('routeRestored');
// Dispatch event to notify AuthContext
window.dispatchEvent(new CustomEvent('sessionExpired'));
}
return Promise.reject(error);
}
);
export const authAPI = { export const authAPI = {
login: async (data: LoginRequest): Promise<AuthResponse> => { login: async (data: LoginRequest): Promise<AuthResponse> => {
const response = await api.post('/auth/login', data); const response = await api.post('/auth/login', data);
@ -68,6 +84,11 @@ export const departmentsAPI = {
return response.data; return response.data;
}, },
getUsers: async (departmentId: number) => {
const response = await api.get(`/departments/${departmentId}/users`);
return response.data;
},
create: async (data: { name: string; description?: string }) => { create: async (data: { name: string; description?: string }) => {
const response = await api.post('/departments/', data); const response = await api.post('/departments/', data);
return response.data; return response.data;
@ -89,6 +110,11 @@ export const channelsAPI = {
const response = await api.post('/channels/', data); const response = await api.post('/channels/', data);
return response.data; return response.data;
}, },
delete: async (channelId: number) => {
const response = await api.delete(`/channels/${channelId}`);
return response.data;
},
}; };
export const messagesAPI = { export const messagesAPI = {
@ -237,10 +263,26 @@ export const directMessagesAPI = {
return response.data; return response.data;
}, },
create: async (data: { content: string; receiver_id: number; snippet_id?: number }) => { create: async (data: { content: string; receiver_id: number; snippet_id?: number; reply_to_id?: number }) => {
const response = await api.post('/direct-messages/', data); const response = await api.post('/direct-messages/', data);
return response.data; return response.data;
}, },
deleteMessage: async (messageId: number) => {
const response = await api.delete(`/direct-messages/${messageId}`);
return response.data;
},
};
export const lastSeenAPI = {
setLastSeen: async (data: { channel_id?: number; dm_user_id?: number; last_seen?: string }) => {
const response = await api.post('/me/last-seen/', null, { params: data });
return response.data;
},
getLastSeen: async (params: { channel_id?: number; dm_user_id?: number }) => {
const response = await api.get('/me/last-seen/', { params });
return response.data;
}
}; };
export const kanbanAPI = { export const kanbanAPI = {
@ -255,6 +297,11 @@ export const kanbanAPI = {
return response.data; return response.data;
}, },
getBoardById: async (boardId: number) => {
const response = await api.get(`/kanban/boards/by-id/${boardId}`);
return response.data;
},
updateBoard: async (boardId: number, data: { name?: string }) => { updateBoard: async (boardId: number, data: { name?: string }) => {
const response = await api.put(`/kanban/boards/${boardId}`, data); const response = await api.put(`/kanban/boards/${boardId}`, data);
return response.data; return response.data;
@ -309,6 +356,11 @@ export const kanbanAPI = {
return response.data; return response.data;
}, },
getArchivedCards: async (boardId: number) => {
const response = await api.get(`/kanban/boards/${boardId}/archived-cards`);
return response.data;
},
moveCard: async (cardId: number, targetColumnId: number, newPosition: number) => { moveCard: async (cardId: number, targetColumnId: number, newPosition: number) => {
const response = await api.put(`/kanban/cards/${cardId}/move`, null, { const response = await api.put(`/kanban/cards/${cardId}/move`, null, {
params: { target_column_id: targetColumnId, new_position: newPosition } params: { target_column_id: targetColumnId, new_position: newPosition }
@ -357,6 +409,180 @@ export const kanbanAPI = {
const response = await api.delete(`/kanban/checklist-items/${itemId}`); const response = await api.delete(`/kanban/checklist-items/${itemId}`);
return response.data; return response.data;
}, },
// Comment API
createComment: async (data: { card_id: number; content: string }) => {
const response = await api.post(`/kanban/cards/${data.card_id}/comments`, { content: data.content });
return response.data;
},
getCardComments: async (cardId: number) => {
const response = await api.get(`/kanban/cards/${cardId}/comments`);
return response.data;
},
getCardActivity: async (cardId: number) => {
const response = await api.get(`/kanban/cards/${cardId}/activity`);
return response.data;
},
updateComment: async (commentId: number, data: { content?: string }) => {
const response = await api.put(`/kanban/comments/${commentId}`, data);
return response.data;
},
deleteComment: async (commentId: number) => {
const response = await api.delete(`/kanban/comments/${commentId}`);
return response.data;
},
// Attachment API
uploadAttachment: async (cardId: number, file: File) => {
const formData = new FormData();
formData.append('file', file);
const response = await api.post(`/kanban/cards/${cardId}/attachments`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
getCardAttachments: async (cardId: number) => {
const response = await api.get(`/kanban/cards/${cardId}/attachments`);
return response.data;
},
deleteAttachment: async (attachmentId: number) => {
const response = await api.delete(`/kanban/attachments/${attachmentId}`);
return response.data;
},
downloadAttachment: async (attachmentId: number) => {
const response = await api.get(`/kanban/attachments/${attachmentId}/download`, {
responseType: 'blob'
});
return response.data;
},
// Time Tracking API
startTimeTracking: async (data: { card_id: number; description?: string }) => {
const response = await api.post('/kanban/cards/time/start', data);
return response.data;
},
stopTimeTracking: async (entryId: number) => {
const response = await api.put(`/kanban/time/${entryId}/stop`);
return response.data;
},
getCardTimeEntries: async (cardId: number) => {
const response = await api.get(`/kanban/cards/${cardId}/time`);
return response.data;
},
// Custom Fields API
createCustomField: async (data: {
board_id: number;
name: string;
field_type: string;
options?: string;
is_required?: boolean;
position: number;
}) => {
const response = await api.post(`/kanban/boards/${data.board_id}/custom-fields`, data);
return response.data;
},
getBoardCustomFields: async (boardId: number) => {
const response = await api.get(`/kanban/boards/${boardId}/custom-fields`);
return response.data;
},
updateCustomField: async (fieldId: number, data: {
name?: string;
field_type?: string;
options?: string;
is_required?: boolean;
position?: number;
}) => {
const response = await api.put(`/kanban/custom-fields/${fieldId}`, data);
return response.data;
},
deleteCustomField: async (fieldId: number) => {
const response = await api.delete(`/kanban/custom-fields/${fieldId}`);
return response.data;
},
setCustomFieldValue: async (cardId: number, fieldId: number, value: string) => {
const response = await api.put(`/kanban/cards/${cardId}/custom-fields/${fieldId}`, { value });
return response.data;
},
getCardCustomFieldValues: async (cardId: number) => {
const response = await api.get(`/kanban/cards/${cardId}/custom-fields`);
return response.data;
},
// Templates API
createTemplate: async (data: {
board_id: number;
name: string;
description?: string;
template_data: string;
is_default?: boolean;
}) => {
const response = await api.post(`/kanban/boards/${data.board_id}/templates`, data);
return response.data;
},
getBoardTemplates: async (boardId: number) => {
const response = await api.get(`/kanban/boards/${boardId}/templates`);
return response.data;
},
createCardFromTemplate: async (templateId: number, columnId: number) => {
const response = await api.post(`/kanban/cards/from-template/${templateId}`, null, {
params: { column_id: columnId }
});
return response.data;
},
// Bulk Operations API
bulkMoveCards: async (data: { card_ids: number[]; column_id: number; position?: number }) => {
const response = await api.post('/kanban/cards/bulk/move', data);
return response.data;
},
bulkDeleteCards: async (data: { card_ids: number[] }) => {
const response = await api.delete('/kanban/cards/bulk', { data });
return response.data;
},
// Search and Filter API
searchCards: async (boardId: number, params: {
q?: string;
assignee_id?: number;
priority?: string;
labels?: string;
}) => {
const response = await api.get(`/kanban/boards/${boardId}/search`, { params });
return response.data;
},
// Extended Card API
getCardExtended: async (cardId: number) => {
const response = await api.get(`/kanban/cards/${cardId}/extended`);
return response.data;
},
};
export const userStatusAPI = {
getUserStatuses: async (): Promise<Array<{user_id: number, username: string, full_name: string, status: string}>> => {
const response = await api.get('/user-status');
return response.data;
},
}; };
export default api; export default api;

View File

@ -1,3 +1,17 @@
export enum UserRole {
USER = 'user',
ADMIN = 'admin',
SUPERADMIN = 'superadmin'
}
export const isAdmin = (user: User | null | undefined): boolean => {
return user?.role === UserRole.ADMIN || user?.role === UserRole.SUPERADMIN;
};
export const isSuperAdmin = (user: User | null | undefined): boolean => {
return user?.role === UserRole.SUPERADMIN;
};
export interface User { export interface User {
id: number; id: number;
username: string; username: string;
@ -6,7 +20,7 @@ export interface User {
profile_picture?: string; profile_picture?: string;
theme?: string; theme?: string;
is_active: boolean; is_active: boolean;
is_admin: boolean; role: UserRole;
created_at: string; created_at: string;
} }
@ -43,6 +57,7 @@ export interface Message {
id: number; id: number;
content: string; content: string;
sender_username: string; sender_username: string;
sender_full_name?: string;
}; };
is_deleted?: boolean; is_deleted?: boolean;
deleted?: boolean; deleted?: boolean;
@ -154,9 +169,15 @@ export interface KanbanCard {
due_date?: string; due_date?: string;
priority?: 'low' | 'medium' | 'high'; priority?: 'low' | 'medium' | 'high';
labels?: string; labels?: string;
estimated_time?: number;
actual_time?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
assignee?: User; assignee?: User;
attachments_count: number;
checklists_count: number;
comments_count: number;
is_archived?: boolean;
} }
export interface KanbanBoardWithColumns extends KanbanBoard { export interface KanbanBoardWithColumns extends KanbanBoard {
@ -164,7 +185,7 @@ export interface KanbanBoardWithColumns extends KanbanBoard {
} }
export interface KanbanColumnWithCards extends KanbanColumn { export interface KanbanColumnWithCards extends KanbanColumn {
cards: KanbanCard[]; cards: KanbanCardExtended[];
} }
// Checklist Types // Checklist Types
@ -195,3 +216,89 @@ export interface KanbanCardWithChecklists extends KanbanCard {
checklists: KanbanChecklistWithItems[]; checklists: KanbanChecklistWithItems[];
} }
// Comment Types
export interface KanbanCardComment {
id: number;
card_id: number;
user_id: number;
content: string;
created_at: string;
updated_at: string;
user?: User;
}
// Attachment Types
export interface KanbanCardAttachment {
id: number;
card_id: number;
filename: string;
original_filename: string;
mime_type: string;
file_size: number;
file_path: string;
uploader_id: number;
uploaded_at: string;
uploader?: User;
}
// Time Tracking Types
export interface KanbanTimeEntry {
id: number;
card_id: number;
user_id: number;
description?: string;
start_time: string;
end_time?: string;
duration_minutes?: number;
is_running: boolean;
created_at: string;
user?: User;
}
// Custom Field Types
export interface KanbanCustomField {
id: number;
board_id: number;
name: string;
field_type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'checkbox';
options?: string; // JSON string for select options
is_required: boolean;
position: number;
created_at: string;
}
export interface KanbanCustomFieldValue {
id: number;
field_id: number;
card_id: number;
value: string; // JSON string for the value
created_at: string;
updated_at: string;
}
// Template Types
export interface KanbanCardTemplate {
id: number;
board_id: number;
name: string;
description?: string;
template_data: string; // JSON string containing template data
is_default: boolean;
created_at: string;
}
// Extended Card Types
export interface KanbanCardExtended extends KanbanCard {
estimated_time?: number;
actual_time?: number;
comments: KanbanCardComment[];
attachments: KanbanCardAttachment[];
time_entries: KanbanTimeEntry[];
custom_field_values: KanbanCustomFieldValue[];
checklists: KanbanChecklistWithItems[];
}
export interface KanbanBoardExtended extends KanbanBoard {
custom_fields: KanbanCustomField[];
templates: KanbanCardTemplate[];
}

View File

@ -44,6 +44,8 @@ server {
location /api/ { location /api/ {
rewrite ^/api/(.*) /$1 break; rewrite ^/api/(.*) /$1 break;
proxy_pass http://192.168.0.12:8000; proxy_pass http://192.168.0.12:8000;
# Rewrite backend Location headers to https so browsers aren't redirected to http
proxy_redirect http:// https://;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
@ -101,6 +103,8 @@ server {
# Auth endpoints # Auth endpoints
location /auth/ { location /auth/ {
proxy_pass http://192.168.0.12:8000/auth/; proxy_pass http://192.168.0.12:8000/auth/;
# Rewrite backend Location headers to https so browsers aren't redirected to http
proxy_redirect http:// https://;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

View File

@ -0,0 +1,17 @@
import asyncio
from app.websocket import manager
async def main():
msg = {
"type": "read_marker",
"user_id": 42,
"channel_id": 4,
"last_seen": "2025-12-12T13:30:00Z",
}
# Broadcast to channel and presence (0)
await manager.broadcast_to_channel(msg, 4)
await manager.broadcast_to_channel({**msg, "type": "read_marker"}, 0)
print('Simulated broadcast sent:', msg)
if __name__ == '__main__':
asyncio.run(main())

12
start-backend-clean.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/bash
# Kill any existing backend processes
echo "Killing existing backend processes..."
pkill -9 -f "uvicorn" 2>/dev/null || true
pkill -9 -f "python3.*app.main" 2>/dev/null || true
sleep 2
# Start fresh backend
echo "Starting backend..."
cd /home/OfficeDesk/backend
python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

View File

@ -6,4 +6,4 @@ echo "🚀 Starte Frontend-Server..."
echo "📍 App: http://192.168.0.12" echo "📍 App: http://192.168.0.12"
echo "" echo ""
npm run dev npm run build