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.staticfiles import StaticFiles
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.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()
@ -14,13 +17,22 @@ app = FastAPI(
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
app.add_middleware(
CORSMiddleware,
allow_origins=[
settings.frontend_url,
"http://localhost:5173",
"http://localhost:5173",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:3000",
"https://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(kanban.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("/")
@ -71,3 +89,73 @@ def read_root():
def health_check():
"""Health check endpoint"""
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"
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
SUPERADMIN = "superadmin"
class Language(SQLModel, table=True):
__tablename__ = "language"
@ -64,7 +70,7 @@ class User(SQLModel, table=True):
profile_picture: Optional[str] = None
theme: str = Field(default="light") # 'light' or 'dark'
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)
# 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"})
receiver: User = Relationship(back_populates="received_direct_messages", sa_relationship_kwargs={"foreign_keys": "DirectMessage.receiver_id"})
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):
@ -180,6 +199,27 @@ class FilePermission(SQLModel, table=True):
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):
__tablename__ = "snippet"
@ -215,6 +255,8 @@ class KanbanBoard(SQLModel, table=True):
# Relationships
channel: Channel = Relationship(back_populates="kanban_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):
@ -245,6 +287,9 @@ class KanbanCard(SQLModel, table=True):
due_date: Optional[datetime] = Field(default=None)
priority: Optional[str] = Field(default="medium") # low, medium, high
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)
updated_at: datetime = Field(default_factory=datetime.utcnow)
@ -252,6 +297,10 @@ class KanbanCard(SQLModel, table=True):
column: KanbanColumn = Relationship(back_populates="cards")
assignee: Optional[User] = Relationship()
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):
@ -282,3 +331,124 @@ class KanbanChecklistItem(SQLModel, table=True):
# Relationships
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 typing import List
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 (
DepartmentCreate, DepartmentResponse,
ChannelCreate, ChannelResponse,
@ -32,7 +32,7 @@ class UserDepartmentAssignment(BaseModel):
class UserAdminUpdate(BaseModel):
user_id: int
is_admin: bool
role: UserRole
class SnippetDepartmentAccess(BaseModel):
@ -43,8 +43,8 @@ class SnippetDepartmentAccess(BaseModel):
def require_admin(current_user: User = Depends(get_current_user)) -> User:
"""Verify that the current user is an admin"""
if not current_user.is_admin:
"""Verify that the current user is an admin or superadmin"""
if current_user.role not in [UserRole.ADMIN, UserRole.SUPERADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required"
@ -52,12 +52,22 @@ def require_admin(current_user: User = Depends(get_current_user)) -> 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 ==========
@router.get("/users", response_model=List[UserResponse])
def get_all_users(
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Get all users (Admin only)"""
statement = select(User)
@ -65,14 +75,14 @@ def get_all_users(
return users
@router.patch("/users/{user_id}/admin")
def toggle_admin_status(
@router.patch("/users/{user_id}/role")
def update_user_role(
user_id: int,
is_admin: bool,
body: UserAdminUpdate,
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)
if not user:
raise HTTPException(
@ -80,12 +90,19 @@ def toggle_admin_status(
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.commit()
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 ==========
@ -94,7 +111,7 @@ def toggle_admin_status(
def create_department(
department_data: DepartmentCreate,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Create a new department (Admin only)"""
department = Department(**department_data.model_dump())
@ -107,7 +124,7 @@ def create_department(
@router.get("/departments", response_model=List[DepartmentResponse])
def get_all_departments(
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Get all departments (Admin only)"""
statement = select(Department)
@ -120,7 +137,7 @@ def update_department(
department_id: int,
department_data: DepartmentCreate,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Update a department (Admin only)"""
department = session.get(Department, department_id)
@ -145,7 +162,7 @@ def toggle_department_snippets(
department_id: int,
enabled: bool,
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)"""
department = session.get(Department, department_id)
@ -171,7 +188,7 @@ def toggle_department_snippets(
def delete_department(
department_id: int,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Delete a department (Admin only)"""
department = session.get(Department, department_id)
@ -209,7 +226,7 @@ def assign_user_to_department(
department_id: int,
user_id: int,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Assign a user to a department (Admin only)"""
# Check if department exists
@ -254,7 +271,7 @@ def remove_user_from_department(
department_id: int,
user_id: int,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Remove a user from a department (Admin only)"""
statement = select(UserDepartmentLink).where(
@ -279,7 +296,7 @@ def remove_user_from_department(
def get_department_members(
department_id: int,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Get all members of a department (Admin only)"""
department = session.get(Department, department_id)
@ -327,6 +344,38 @@ def create_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}")
def delete_channel(
channel_id: int,
@ -352,7 +401,7 @@ def delete_channel(
@router.get("/languages", response_model=List[LanguageResponse])
def get_languages(
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""List all available UI languages."""
ensure_default_languages(session)
@ -364,7 +413,7 @@ def get_languages(
def create_language(
language_data: LanguageCreate,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Create a new UI language."""
code = language_data.code.strip().lower()
@ -408,7 +457,7 @@ def create_language(
def delete_language(
language_id: int,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Remove a UI language."""
language = session.get(Language, language_id)
@ -438,7 +487,7 @@ def delete_language(
@router.get("/translations", response_model=List[TranslationGroupResponse])
def get_translations(
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Retrieve translation values grouped by attribute."""
ensure_default_languages(session)
@ -498,7 +547,7 @@ def get_translations(
def update_translation(
payload: TranslationUpdateRequest,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Update a single translation entry."""
translation = session.get(Translation, payload.translation_id)
@ -527,7 +576,7 @@ def update_translation(
def get_snippet_departments(
snippet_id: int,
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.
By default, snippets are disabled for all departments.
@ -563,7 +612,7 @@ def get_snippet_departments(
def toggle_snippet_department_access(
access_data: SnippetDepartmentAccess,
session: Session = Depends(get_session),
admin: User = Depends(require_admin)
admin: User = Depends(require_superadmin)
):
"""Enable or disable a snippet for a specific department.
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 typing import List
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.auth import get_current_user
@ -107,7 +107,7 @@ def get_channel(
user = session.exec(statement).first()
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(
status_code=status.HTTP_403_FORBIDDEN,
detail="You don't have access to this channel"
@ -128,7 +128,7 @@ def get_channels_by_department(
user = session.exec(statement).first()
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(
status_code=status.HTTP_403_FORBIDDEN,
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 app.database import get_session
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
router = APIRouter(prefix="/departments", tags=["Departments"])
@ -59,6 +59,32 @@ def get_my_departments(
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}")
def add_user_to_department(
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 typing import List
from sqlalchemy.orm import joinedload
from typing import List, Optional
from app.database import get_session
from app.models import DirectMessage, User
from app.schemas import DirectMessageCreate, DirectMessageResponse
from app.models import DirectMessage, User, Snippet, DirectMessageAttachment
from app.schemas import DirectMessageCreate, DirectMessageResponse, DirectMessageAttachmentResponse
from app.auth import get_current_user
from app.websocket import manager
import os
import uuid
from pathlib import Path
router = APIRouter(prefix="/direct-messages", tags=["Direct Messages"])
@ -36,19 +40,82 @@ async def create_direct_message(
content=message_data.content,
sender_id=current_user.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.commit()
session.refresh(new_message)
# 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
# Load snippet data if present
snippet_data = None
if new_message.snippet_id:
snippet = session.get(Snippet, new_message.snippet_id)
if snippet:
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")
response_data = {
@ -63,7 +130,10 @@ async def create_direct_message(
"created_at": new_message.created_at.isoformat(),
"is_read": new_message.is_read,
"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"
@ -76,9 +146,52 @@ async def create_direct_message(
-current_user.id
)
# Update user activity
manager.update_activity(current_user.id)
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])
def get_conversation(
user_id: int,
@ -105,6 +218,7 @@ def get_conversation(
and_(DirectMessage.sender_id == user_id, DirectMessage.receiver_id == current_user.id)
)
)
.options(joinedload(DirectMessage.snippet))
.order_by(DirectMessage.created_at.desc())
.offset(offset)
.limit(limit)
@ -121,13 +235,76 @@ def get_conversation(
# Build responses
responses = []
for msg in messages:
msg_response = DirectMessageResponse.model_validate(msg)
sender = session.get(User, msg.sender_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"
msg_response.sender_full_name = sender.full_name if sender else None
msg_response.sender_profile_picture = sender.profile_picture if sender else None
# Build reply_to data if it exists
reply_to_data = 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)
# Reverse to show oldest first
@ -186,3 +363,149 @@ def get_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
from urllib.parse import quote
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.auth import get_current_user
from app.config import get_settings
@ -266,7 +266,8 @@ async def upload_file_with_message(
reply_to_data = {
"id": reply_msg.id,
"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 = {
@ -409,7 +410,7 @@ async def update_file_permission(
)
# 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(
status_code=status.HTTP_403_FORBIDDEN,
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
import os
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.auth import get_current_user
from app.websocket import manager
@ -66,7 +66,22 @@ async def create_message(
reply_to_data = {
"id": reply_msg.id,
"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 = {
@ -81,7 +96,7 @@ async def create_message(
"snippet_id": new_message.snippet_id,
"reply_to_id": new_message.reply_to_id,
"reply_to": reply_to_data,
"snippet": None,
"snippet": snippet_data,
"attachments": [],
"is_deleted": False
}
@ -95,6 +110,9 @@ async def create_message(
message_data.channel_id
)
# Update user activity
manager.update_activity(current_user.id)
# Return proper response
response = MessageResponse.model_validate(new_message)
response.sender_username = current_user.username
@ -124,6 +142,7 @@ def get_channel_messages(
select(Message)
.where(Message.channel_id == channel_id)
.options(joinedload(Message.attachments))
.options(joinedload(Message.snippet))
.order_by(Message.created_at.desc())
.offset(offset)
.limit(limit)
@ -147,7 +166,8 @@ def get_channel_messages(
msg_response.reply_to = {
"id": reply_msg.id,
"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)

View File

@ -3,7 +3,7 @@ from sqlmodel import Session, select, or_, and_
from typing import List, Optional
from datetime import datetime
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 (
SnippetCreate,
SnippetUpdate,
@ -83,7 +83,7 @@ def create_snippet(
# Check if user belongs to that department
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(
status_code=status.HTTP_403_FORBIDDEN,
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 app.database import get_session
from app.websocket import manager
from app.auth import decode_access_token
from app.models import User, Channel
from app.auth import decode_access_token, get_current_user
from app.models import User, Channel, UserRole
from sqlmodel import select
import json
router = APIRouter()
router = APIRouter(tags=["WebSocket"])
@router.websocket("/ws/{channel_id}")
@ -34,12 +34,13 @@ async def websocket_endpoint(
return
# Negative channel_id means direct messages (user_id)
# channel_id 0 means presence-only connection
if channel_id < 0:
# Direct message connection - verify it's the user's own connection
if -channel_id != user.id:
await websocket.close(code=1008, reason="Access denied")
return
else:
elif channel_id > 0:
# Regular channel - verify channel exists and user has access
channel = session.get(Channel, channel_id)
if not channel:
@ -47,12 +48,16 @@ async def websocket_endpoint(
return
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")
return
# channel_id 0 is allowed for presence-only connections
# 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:
# Send welcome message
@ -89,7 +94,11 @@ async def websocket_endpoint(
)
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:
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}")

View File

@ -1,6 +1,13 @@
from pydantic import BaseModel, EmailStr
from typing import Optional, List
from datetime import datetime
from enum import Enum
class UserRole(str, Enum):
USER = "user"
ADMIN = "admin"
SUPERADMIN = "superadmin"
# User Schemas
@ -31,7 +38,7 @@ class UserLogin(BaseModel):
class UserResponse(UserBase):
id: int
is_active: bool
is_admin: bool = False
role: UserRole = UserRole.USER
created_at: datetime
class Config:
@ -141,6 +148,28 @@ class DirectMessageResponse(DirectMessageBase):
sender_profile_picture: Optional[str] = None
snippet: Optional["SnippetResponse"] = None
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:
from_attributes = True
@ -296,6 +325,8 @@ class KanbanCardBase(BaseModel):
due_date: Optional[datetime] = None
priority: Optional[str] = "medium"
labels: Optional[str] = None
estimated_time: Optional[int] = None
actual_time: Optional[int] = None
class KanbanCardCreate(KanbanCardBase):
@ -310,6 +341,9 @@ class KanbanCardUpdate(BaseModel):
due_date: Optional[datetime] = None
priority: 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):
@ -317,6 +351,11 @@ class KanbanCardResponse(KanbanCardBase):
column_id: int
created_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:
from_attributes = True
@ -388,3 +427,181 @@ class KanbanChecklistWithItems(KanbanChecklistResponse):
class KanbanCardWithChecklists(KanbanCardResponse):
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 typing import Dict, List
from typing import Dict, List, Optional
import json
import time
from datetime import datetime, timedelta
class ConnectionManager:
def __init__(self):
# Maps channel_id to list of WebSocket connections
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"""
await websocket.accept()
if channel_id not in self.active_connections:
self.active_connections[channel_id] = []
self.active_connections[channel_id].append(websocket)
# 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):
def disconnect(self, websocket: WebSocket, channel_id: int, user_id: int):
"""Remove a WebSocket connection"""
if channel_id in self.active_connections:
if websocket in self.active_connections[channel_id]:
@ -24,6 +36,30 @@ class ConnectionManager:
# Clean up empty channel lists
if not 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):
"""Send a message to a specific WebSocket"""
@ -42,9 +78,43 @@ class ConnectionManager:
# Mark for removal if send fails
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:
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

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"
},
"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-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"axios": "^1.6.2",
"prism-react-renderer": "^1.3.5"
"react-router-dom": "^6.20.0"
},
"devDependencies": {
"@types/react": "^18.2.43",

View File

@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from './contexts/AuthContext';
import { isAdmin } from './types';
import Login from './components/Auth/Login';
import Register from './components/Auth/Register';
import ChatView from './components/Chat/ChatView';
@ -22,7 +23,7 @@ const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <Navigate to="/login" />;
}
if (!user?.is_admin) {
if (!isAdmin(user)) {
return <Navigate to="/" />;
}
@ -38,7 +39,7 @@ const AppContent: React.FC = () => {
// Speichere nur Pfade innerhalb der geschützten Bereiche (nicht login/register)
if (isAuthenticated && !location.pathname.startsWith('/login') && !location.pathname.startsWith('/register')) {
// 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
}
localStorage.setItem('lastVisitedPath', location.pathname + location.search);
@ -85,7 +86,7 @@ const RouteRestorer: React.FC = () => {
if (lastVisitedPath && lastVisitedPath !== '/') {
// Prüfe, ob der Pfad gültig ist
const isAdminPath = lastVisitedPath.startsWith('/admin');
if (isAdminPath && !user.is_admin) {
if (isAdminPath && !isAdmin(user)) {
// Benutzer hat keinen Admin-Zugriff, bleibe auf der Hauptseite
localStorage.removeItem('lastVisitedPath');
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 { useAuth } from '../../contexts/AuthContext';
import api, { adminLanguagesAPI, adminTranslationsAPI, snippetsAPI } from '../../services/api';
import type {
Channel,
@ -8,6 +9,7 @@ import type {
TranslationGroup,
User,
} from '../../types';
import { UserRole, isSuperAdmin } from '../../types';
type TabKey = 'users' | 'departments' | 'channels' | 'snippets' | 'languages';
@ -26,6 +28,7 @@ type SnippetAccessEntry = {
};
const AdminPanel: React.FC = () => {
const { user: currentUser } = useAuth();
const [activeTab, setActiveTab] = useState<TabKey>('users');
const [error, setError] = useState<string | null>(null);
@ -65,6 +68,11 @@ const AdminPanel: React.FC = () => {
const [newChannelDesc, setNewChannelDesc] = useState('');
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 [snippetAccess, setSnippetAccess] = useState<SnippetAccessEntry[]>([]);
@ -252,14 +260,18 @@ const AdminPanel: React.FC = () => {
usersLoaded,
]);
const toggleAdmin = useCallback(
async (userId: number, isAdmin: boolean) => {
const updateUserRole = useCallback(
async (userId: number, newRole: UserRole) => {
setError(null);
try {
await api.patch(`/admin/users/${userId}/admin`, null, { params: { is_admin: !isAdmin } });
setUsers((prev) => prev.map((user) => (user.id === userId ? { ...user, is_admin: !isAdmin } : user)));
await api.patch(`/admin/users/${userId}/role`, { role: newRole });
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) {
setGlobalError('Admin-Status konnte nicht geändert werden.');
setGlobalError('Rolle konnte nicht geändert werden.');
}
},
[setGlobalError]
@ -463,6 +475,45 @@ const AdminPanel: React.FC = () => {
[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(
async (snippetId: number, departmentId: number, enabled: boolean) => {
setError(null);
@ -672,7 +723,11 @@ const AdminPanel: React.FC = () => {
{user.full_name || '-'}
</td>
<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">
Admin
</span>
@ -683,12 +738,19 @@ const AdminPanel: React.FC = () => {
)}
</td>
<td className="px-4 py-3 whitespace-nowrap text-xs">
<button
onClick={() => toggleAdmin(user.id, user.is_admin)}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{user.is_admin ? 'Admin entfernen' : 'Admin machen'}
</button>
{isSuperAdmin(currentUser) && user.id !== currentUser?.id ? (
<select
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"
>
<option value={UserRole.USER}>User</option>
<option value={UserRole.ADMIN}>Admin</option>
<option value={UserRole.SUPERADMIN}>SuperAdmin</option>
</select>
) : (
<span className="text-gray-500">-</span>
)}
</td>
</tr>
))}
@ -1026,7 +1088,72 @@ const AdminPanel: React.FC = () => {
<div className="space-y-2">
{channels.map((channel) => {
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
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"
@ -1042,12 +1169,20 @@ const AdminPanel: React.FC = () => {
Abteilung: {dept?.name || `ID ${channel.department_id}`}
</p>
</div>
<button
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"
>
Löschen
</button>
<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
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"
>
Löschen
</button>
</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">
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
</Link>
</p>

View File

@ -98,7 +98,7 @@ const Register: React.FC = () => {
<p className="mt-4 text-center text-gray-600 dark:text-gray-400">
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
</Link>
</p>

View File

@ -6,13 +6,15 @@ import MessageInput from './MessageInput';
import Sidebar from './Sidebar';
import DirectMessagesSidebar from './DirectMessagesSidebar';
import DirectMessageView from './DirectMessageView';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
const ChatView: React.FC = () => {
const [channels, setChannels] = useState<Channel[]>([]);
const [departments, setDepartments] = useState<Department[]>([]);
const [selectedChannel, setSelectedChannel] = useState<Channel | 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);
useEffect(() => {
@ -55,21 +57,22 @@ const ChatView: React.FC = () => {
onSelectChannel={(channel) => {
setSelectedChannel(channel);
setSelectedUser(null);
setActiveChannel(channel.id);
setActiveDirectMessage(null);
markChannelAsRead(channel.id);
}}
/>
<div className="flex-1 flex flex-col">
{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">
# {selectedChannel.name}
</h2>
{selectedChannel.description && (
<p className="text-xs text-gray-600 dark:text-gray-400">
{selectedChannel.description}
</p>
)}
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0 h-5">
{selectedChannel.description || ''}
</p>
</div>
<MessageList
@ -110,6 +113,9 @@ const ChatView: React.FC = () => {
onSelectUser={(user) => {
setSelectedUser(user);
setSelectedChannel(null);
setActiveDirectMessage(user.id);
setActiveChannel(null);
markDirectMessageAsRead(user.id);
}}
/>
</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 { directMessagesAPI } from '../../services/api';
import { directMessagesAPI, getApiUrl } from '../../services/api';
import { useAuth } from '../../contexts/AuthContext';
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 {
id: number;
@ -16,6 +19,15 @@ interface DirectMessage {
created_at: string;
is_read: boolean;
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 {
@ -23,13 +35,15 @@ interface DirectMessageViewProps {
}
const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
const { addToast } = useToast();
const { getUserStatus } = useUserStatus();
const [messages, setMessages] = useState<DirectMessage[]>([]);
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
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(() => {
loadMessages();
@ -37,24 +51,44 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
// Set up WebSocket for real-time updates
const token = localStorage.getItem('token');
if (token && currentUser) {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = import.meta.env.VITE_WS_URL || `${wsProtocol}//localhost:8000`;
const ws = new WebSocket(`${wsHost}/ws/${-currentUser.id}?token=${token}`);
// 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/${-currentUser.id}?token=${token}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('DirectMessage WebSocket connected for user:', currentUser.id);
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'direct_message') {
// Only add if message is from/to the selected user
const msg = data.message;
if (
(msg.sender_id === user.id && msg.receiver_id === currentUser.id) ||
(msg.sender_id === currentUser.id && msg.receiver_id === user.id)
) {
setMessages((prevMessages) => [...prevMessages, msg]);
try {
const data = JSON.parse(event.data);
if (data.type === 'direct_message') {
// Only add if message is from/to the selected user
const msg = data.message;
if (
(msg.sender_id === user.id && msg.receiver_id === currentUser.id) ||
(msg.sender_id === currentUser.id && msg.receiver_id === user.id)
) {
// Message marking is now handled globally in UnreadMessagesContext
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 () => {
ws.close();
};
@ -65,6 +99,52 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
scrollToBottom();
}, [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 () => {
try {
const data = await directMessagesAPI.getConversation(user.id);
@ -80,29 +160,13 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
const handleSend = async () => {
if (!content.trim()) return;
setSending(true);
const handleDeleteMessage = async (messageId: number) => {
try {
await directMessagesAPI.create({
content,
receiver_id: user.id,
});
setContent('');
await directMessagesAPI.deleteMessage(messageId);
// Remove the message from the UI
setMessages(messages.filter(m => m.id !== messageId));
} catch (error) {
console.error('Failed to send 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();
console.error('Failed to delete message:', error);
}
};
@ -129,74 +193,194 @@ const DirectMessageView: React.FC<DirectMessageViewProps> = ({ user }) => {
<>
{/* Messages */}
<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 markerBefore = firstUnreadIndex !== null && firstUnreadIndex === index;
return (
<div key={message.id} className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}>
<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 ? (
<img
src={`http://localhost:8000/${message.sender_profile_picture}`}
alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{getInitials(message.sender_full_name, message.sender_username)}
</div>
)}
{/* Message Bubble */}
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline space-x-2 mb-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_username}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<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' : ''}`}>
{message.sender_profile_picture ? (
<img
src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{getInitials(message.sender_full_name, message.sender_username)}
</div>
)}
<div className={`px-3 py-1 rounded-lg ${
isOwnMessage
? 'bg-blue-500 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'
}`}>
<div className="text-sm whitespace-pre-wrap break-words">
{message.content}
<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-center space-x-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_full_name || message.sender_username || 'Unknown'}
</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">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className={`px-1 py-1 rounded-lg relative ${
isOwnMessage
? 'bg-blue-500 bg-opacity-80 text-white rounded-br-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 */}
<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}
</div>
)}
</div>
</div>
</div>
</div>
</div>
</React.Fragment>
);
})}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 p-3">
<div className="flex items-end space-x-2">
<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()}
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>
<DirectMessageInput
userId={user.id}
onMessageSent={loadMessages}
replyTo={replyTo}
onCancelReply={() => setReplyTo(null)}
/>
</>
);
};

View File

@ -1,7 +1,12 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { useUserStatus } from '../../contexts/UserStatusContext';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
import axios from 'axios';
import { getApiUrl } from '../../services/api';
import type { User } from '../../types';
import UserStatusIndicator from '../common/UserStatusIndicator';
import BlinkingEnvelope from '../common/BlinkingEnvelope';
interface DirectMessagesSidebarProps {
onSelectUser: (user: User) => void;
@ -18,6 +23,8 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
const [showUserPicker, setShowUserPicker] = useState(false);
const [loading, setLoading] = useState(false);
const { user: currentUser } = useAuth();
const { getUserStatus } = useUserStatus();
const { hasUnreadDirectMessage, markDirectMessageAsUnread } = useUnreadMessages();
useEffect(() => {
// Load conversations on mount
@ -27,7 +34,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
const loadConversations = async () => {
try {
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: {
Authorization: `Bearer ${token}`
}
@ -49,7 +56,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
setLoading(true);
try {
const token = localStorage.getItem('token');
const response = await axios.get('http://localhost:8000/admin/users', {
const response = await axios.get(getApiUrl('/admin/users'), {
headers: {
Authorization: `Bearer ${token}`
}
@ -84,7 +91,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
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="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>
<button
onClick={() => {
@ -112,6 +119,7 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
<button
key={user.id}
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 ${
selectedUserId === user.id
? '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">
<span className="w-2 h-2 rounded-full bg-green-500"></span>
<UserStatusIndicator status={getUserStatus(user.id)} size="sm" />
<span>{user.username}</span>
<BlinkingEnvelope hasNewMessages={hasUnreadDirectMessage(user.id)} />
</div>
{user.full_name && (
<div className="text-xs text-gray-500 dark:text-gray-400 ml-4">
@ -183,19 +192,28 @@ const DirectMessagesSidebar: React.FC<DirectMessagesSidebarProps> = ({
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"
>
<div className="font-medium text-sm text-gray-900 dark:text-white">
{user.username}
<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">
{user.username}
</div>
{user.full_name && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{user.full_name}
</div>
)}
{user.email && (
<div className="text-xs text-gray-500 dark:text-gray-500 mt-0.5">
{user.email}
</div>
)}
</div>
<BlinkingEnvelope hasNewMessages={hasUnreadDirectMessage(user.id)} />
</div>
</div>
{user.full_name && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-0.5">
{user.full_name}
</div>
)}
{user.email && (
<div className="text-xs text-gray-500 dark:text-gray-500 mt-0.5">
{user.email}
</div>
)}
</button>
))}
</div>

View File

@ -7,7 +7,7 @@ import { useToast } from '../../contexts/ToastContext';
interface MessageInputProps {
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;
}
@ -85,18 +85,18 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
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-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="text-xs font-medium text-indigo-900 dark:text-indigo-100">
Replying to {replyTo.sender_username}
<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-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}
</div>
</div>
<button
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>
@ -104,13 +104,13 @@ const MessageInput: React.FC<MessageInputProps> = ({ channelId, replyTo, onCance
)}
{selectedSnippet && (
<div className="mb-2 p-2 bg-indigo-100 dark:bg-indigo-900 rounded flex items-center justify-between">
<span className="text-xs text-indigo-900 dark:text-indigo-100">
📋 {selectedSnippet.title} ({selectedSnippet.language})
<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-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>

View File

@ -1,19 +1,24 @@
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 CodeBlock from '../common/CodeBlock';
import { useAuth } from '../../contexts/AuthContext';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
import { useToast } from '../../contexts/ToastContext';
import { useUserStatus } from '../../contexts/UserStatusContext';
import UserStatusIndicator from '../common/UserStatusIndicator';
interface MessageListProps {
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 { user } = useAuth();
const { addToast } = useToast();
const { getUserStatus } = useUserStatus();
const [messages, setMessages] = useState<Message[]>([]);
const [firstUnreadIndex, setFirstUnreadIndex] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [hasMore, setHasMore] = useState(true);
const [openMenuId, setOpenMenuId] = useState<number | null>(null);
@ -21,7 +26,7 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const wsRef = useRef<WebSocket | null>(null);
const MESSAGES_LIMIT = 15;
const MESSAGES_LIMIT = 10;
const getInitials = (fullName?: string, username?: string) => {
if (fullName) {
@ -40,10 +45,11 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
// Set up WebSocket for real-time updates
const token = localStorage.getItem('token');
if (token && channelId > 0) {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsHost = import.meta.env.VITE_WS_URL || `${wsProtocol}//localhost:8000`;
const wsUrl = `${wsHost}/ws/${channelId}?token=${token}`;
console.log('Connecting to WebSocket:', wsUrl);
// Use API URL to determine WebSocket host and add /ws path
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/${channelId}?token=${token}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
@ -53,36 +59,41 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'message') {
// Add new message immediately to the list, but avoid duplicates
setMessages((prevMessages) => {
// Check if message already exists
if (prevMessages.some(msg => msg.id === data.message.id)) {
return prevMessages;
}
const updated = [...prevMessages, data.message];
// Keep only the most recent messages when limit is exceeded
if (updated.length > MESSAGES_LIMIT * 3) {
return updated.slice(-MESSAGES_LIMIT * 2);
}
return updated;
});
} else if (data.type === 'message_deleted') {
// Replace deleted message with placeholder
setMessages((prevMessages) =>
prevMessages.map(msg =>
msg.id === data.message_id
? { ...msg, deleted: true }
: msg
)
);
try {
const data = JSON.parse(event.data);
if (data.type === 'message') {
// Message marking is now handled globally in UnreadMessagesContext
// Add new message immediately to the list, but avoid duplicates
setMessages((prevMessages) => {
// Check if message already exists
if (prevMessages.some(msg => msg.id === data.message.id)) {
return prevMessages;
}
const updated = [...prevMessages, data.message];
// Keep only the most recent messages when limit is exceeded
if (updated.length > MESSAGES_LIMIT * 3) {
return updated.slice(-MESSAGES_LIMIT * 2);
}
return updated;
});
} else if (data.type === 'message_deleted') {
// Replace deleted message with placeholder
setMessages((prevMessages) =>
prevMessages.map(msg =>
msg.id === data.message_id
? { ...msg, deleted: true }
: msg
)
);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
ws.onerror = (error) => {
console.error('WebSocket error for channel', channelId, ':', error);
console.error('WebSocket URL was:', wsUrl);
};
ws.onclose = (event) => {
@ -110,6 +121,61 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
scrollToBottom();
}, [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) => {
try {
const offset = append ? messages.length : 0;
@ -263,57 +329,79 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<span className="text-xs text-gray-500 dark:text-gray-400">Lade ältere Nachrichten...</span>
</div>
)}
{messages.map((message) => {
{messages.map((message, index) => {
const isOwnMessage = user && message.sender_id === user.id;
// Deleted message - simple text without bubble (check both deleted and is_deleted)
if (message.deleted || message.is_deleted) {
const markerBefore = firstUnreadIndex !== null && firstUnreadIndex === index;
return (
<div
key={message.id}
id={`message-${message.id}`}
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' : ''}`}>
{message.sender_profile_picture ? (
<img
src={`http://localhost:8000/${message.sender_profile_picture}`}
alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{getInitials(message.sender_full_name, message.sender_username)}
</div>
)}
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline space-x-2 mb-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_username || 'Unknown'}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
<React.Fragment key={message.id}>
<div
id={`message-${message.id}`}
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' : ''}`}>
{message.sender_profile_picture ? (
<img
src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username}
className="w-8 h-8 rounded-full object-cover flex-shrink-0"
/>
) : (
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{getInitials(message.sender_full_name, message.sender_username)}
</div>
)}
<div className={`flex flex-col ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<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">
{message.sender_full_name || message.sender_username || 'Unknown'}
</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">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
Diese Nachricht wurde gelöscht
</span>
</div>
<span className="text-xs text-gray-400 dark:text-gray-500 italic">
Diese Nachricht wurde gelöscht
</span>
</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 (
<div
key={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`}
>
<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' : ''}`}>
{message.sender_profile_picture ? (
<img
src={`http://localhost:8000/${message.sender_profile_picture}`}
src={getApiUrl(message.sender_profile_picture)}
alt={message.sender_username}
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 items-baseline space-x-2 mb-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_username || 'Unknown'}
</span>
<div className="flex items-center space-x-1">
<span className="font-semibold text-xs text-gray-900 dark:text-white">
{message.sender_full_name || message.sender_username || 'Unknown'}
</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">
{new Date(message.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</span>
@ -335,8 +428,8 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
<div className={`px-1 py-1 rounded-lg relative ${
isOwnMessage
? 'bg-blue-500 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-blue-500 bg-opacity-80 text-white rounded-br-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 */}
<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({
id: message.id,
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"
@ -375,16 +469,18 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
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={() => {
// TODO: Implement private message
addToast('Private Nachricht an ' + message.sender_username, 'info');
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"
>
Private Nachricht
</button>
{!isOwnMessage && (
<button
onClick={() => {
// TODO: Implement private message
addToast('Private Nachricht an ' + message.sender_username, 'info');
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"
>
Private Nachricht
</button>
)}
<button
onClick={() => {
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)}
>
<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 className="text-xs text-gray-600 dark:text-gray-400 truncate">
{message.reply_to.content}
@ -732,8 +828,9 @@ const MessageList: React.FC<MessageListProps> = ({ channelId, onReply }) => {
)}
</div>
</div>
</div>
</div>
</div>
</React.Fragment>
);
})}
<div ref={messagesEndRef} />

View File

@ -1,11 +1,14 @@
import React from 'react';
import type { Channel, Department } from '../../types';
import { useUnreadMessages } from '../../contexts/UnreadMessagesContext';
import BlinkingEnvelope from '../common/BlinkingEnvelope';
interface SidebarProps {
channels: Channel[];
departments: Department[];
selectedChannel: Channel | null;
onSelectChannel: (channel: Channel) => void;
onDeleteChannel?: (channel: Channel) => void;
}
const Sidebar: React.FC<SidebarProps> = ({
@ -13,7 +16,10 @@ const Sidebar: React.FC<SidebarProps> = ({
departments,
selectedChannel,
onSelectChannel,
onDeleteChannel,
}) => {
const { hasUnreadChannel, markChannelAsUnread } = useUnreadMessages();
// Group channels by department
const channelsByDept = channels.reduce((acc, channel) => {
if (!acc[channel.department_id]) {
@ -23,31 +29,71 @@ const Sidebar: React.FC<SidebarProps> = ({
return acc;
}, {} 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 (
<div className="w-52 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="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-3 border-b border-gray-200 dark:border-gray-700">
<h3 className="font-semibold text-base text-gray-900 dark:text-white">Channels</h3>
</div>
<div className="flex-1 overflow-y-auto">
{departments.map((dept) => (
<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}
</div>
{channelsByDept[dept.id]?.map((channel) => (
<button
key={channel.id}
onClick={() => onSelectChannel(channel)}
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
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
: 'text-gray-700 dark:text-gray-300'
}`}
>
# {channel.name}
</button>
<div key={channel.id} className="relative group">
<button
onClick={() => handleChannelClick(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 ${
selectedChannel?.id === channel.id
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
: 'text-gray-700 dark:text-gray-300'
}`}
>
<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>
{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>
))}

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 { useToast } from '../../contexts/ToastContext';
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 KanbanCardModal from './KanbanCardModal';
import KanbanArchiveModal from './KanbanArchiveModal';
import KanbanSidebar from './KanbanSidebar';
import CreateCardDialog from './CreateCardDialog';
import ConfirmDialog from '../Common/ConfirmDialog';
const KanbanBoard: React.FC = () => {
const { channelId } = useParams<{ channelId: string }>();
@ -18,8 +21,14 @@ const KanbanBoard: React.FC = () => {
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
const [loading, setLoading] = 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 [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(() => {
loadSidebarData();
@ -70,13 +79,13 @@ const KanbanBoard: React.FC = () => {
if (error.response?.status === 404) {
// Board doesn't exist yet, create it with default columns
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'];
for (let i = 0; i < defaultColumns.length; i++) {
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],
position: i
});
@ -145,16 +154,20 @@ const KanbanBoard: React.FC = () => {
}
};
const handleCreateCard = async (columnId: number) => {
const cardTitle = prompt('Kartentitel eingeben:');
if (!cardTitle?.trim()) return;
const handleCreateCard = (columnId: number) => {
setCreateCardColumnId(columnId);
setShowCreateCardDialog(true);
};
const handleConfirmCreateCard = async (cardTitle: string) => {
if (!createCardColumnId) return;
try {
const column = board?.columns.find(col => col.id === columnId);
const column = board?.columns.find(col => col.id === createCardColumnId);
if (!column) return;
const newCard = await kanbanAPI.createCard({
column_id: columnId,
column_id: createCardColumnId,
title: cardTitle,
position: column.cards.length
});
@ -162,50 +175,67 @@ const KanbanBoard: React.FC = () => {
setBoard(prev => prev ? {
...prev,
columns: prev.columns.map(col =>
col.id === columnId
col.id === createCardColumnId
? { ...col, cards: [...col.cards, newCard] }
: col
)
} : null);
addToast('Karte erstellt', 'success');
setShowCreateCardDialog(false);
setCreateCardColumnId(null);
} catch (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 {
await kanbanAPI.updateCard(cardId, updates);
setBoard(prev => prev ? {
...prev,
columns: prev.columns.map(col => ({
...col,
cards: col.cards.map(card =>
card.id === cardId ? { ...card, ...updates } : card
)
}))
} : null);
} catch (error) {
addToast('Fehler beim Aktualisieren der Karte', 'error');
// 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 ? {
...prev,
columns: prev.columns.map(col => ({
...col,
cards: col.cards.map(card =>
card.id === cardId ? { ...card, ...updates } : card
)
}))
} : null);
}
} catch (error: any) {
addToast('Fehler beim Aktualisieren der Karte: ' + (error.response?.data?.detail || error.message), 'error');
}
};
const handleDeleteCard = async (cardId: number) => {
if (!confirm('Karte wirklich löschen?')) return;
const handleDeleteCard = (cardId: number) => {
setDeleteCardId(cardId);
setShowDeleteCardDialog(true);
};
const handleConfirmDeleteCard = async () => {
if (!deleteCardId) return;
try {
await kanbanAPI.deleteCard(cardId);
await kanbanAPI.deleteCard(deleteCardId);
setBoard(prev => prev ? {
...prev,
columns: prev.columns.map(col => ({
...col,
cards: col.cards.filter(card => card.id !== cardId)
cards: col.cards.filter(card => card.id !== deleteCardId)
}))
} : null);
addToast('Karte gelöscht', 'success');
addToast('Karte in Archiv verschoben', 'success');
setShowDeleteCardDialog(false);
setDeleteCardId(null);
} 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) => {
setSelectedCard(card);
setShowCardModal(true);
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);
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) {
@ -285,45 +338,95 @@ const KanbanBoard: React.FC = () => {
Kanban-Board nicht gefunden
</div>
) : (
<div className="flex-1 p-4 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex-1 flex flex-col">
{/* 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>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
{board.name}
</h1>
{selectedChannel && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
#{selectedChannel.name}
</p>
<>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">
# {selectedChannel.name}
</h2>
<p className="text-xs text-gray-600 dark:text-gray-400 mt-0 h-5">
{selectedChannel.description || ''}
</p>
</>
)}
</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 className="flex gap-4 overflow-x-auto pb-4">
{board.columns.map((column) => (
<KanbanColumnComponent
key={column.id}
column={column}
onUpdateColumn={handleUpdateColumn}
onDeleteColumn={handleDeleteColumn}
onCreateCard={handleCreateCard}
onDeleteCard={handleDeleteCard}
onMoveCard={handleMoveCard}
onCardClick={handleCardClick}
{/* Board Content */}
<div className="flex-1 p-4 overflow-y-auto">
<div className="flex gap-4">
{board.columns.map((column) => (
<KanbanColumnComponent
key={column.id}
column={column}
onUpdateColumn={handleUpdateColumn}
onDeleteColumn={handleDeleteColumn}
onCreateCard={handleCreateCard}
onDeleteCard={handleDeleteCard}
onMoveCard={handleMoveCard}
onCardClick={handleCardClick}
/>
))}
</div>
{showCardModal && selectedCard && board && (
<KanbanCardModal
card={selectedCard}
onClose={() => {
setShowCardModal(false);
setSelectedCard(null);
}}
onUpdate={handleUpdateCard}
/>
))}
)}
</div>
{showCardModal && selectedCard && (
<KanbanCardModal
card={selectedCard}
onClose={() => {
setShowCardModal(false);
setSelectedCard(null);
{/* Archive Modal */}
{board && (
<KanbanArchiveModal
isOpen={showArchiveModal}
onClose={() => setShowArchiveModal(false)}
archivedCards={archivedCards}
onSelectCard={(card) => {
setSelectedCard(card as KanbanCardExtended);
setShowCardModal(true);
}}
onUpdate={handleUpdateCard}
/>
)}
{/* 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>

View File

@ -1,8 +1,9 @@
import React from 'react';
import type { KanbanCard } from '../../types';
import AccountIcon from 'mdi-react/AccountIcon';
import type { KanbanCardExtended } from '../../types';
interface KanbanCardProps {
card: KanbanCard;
card: KanbanCardExtended;
onClick: () => void;
onDelete: (cardId: number) => void;
sourceColumnId: number;
@ -66,8 +67,8 @@ const KanbanCard: React.FC<KanbanCardProps> = ({
)}
{/* Priority and Due Date */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2 flex-wrap">
{card.priority && (
<span className={`px-1.5 py-0.5 text-xs rounded border ${getPriorityColor(card.priority)}`}>
{card.priority === 'high' ? 'Hoch' :
@ -86,28 +87,52 @@ const KanbanCard: React.FC<KanbanCardProps> = ({
)}
</div>
{/* Assignee */}
{/* Assignee Name with Icon */}
{card.assignee && (
<div className="flex items-center">
{card.assignee.profile_picture ? (
<img
src={`http://localhost:8000/${card.assignee.profile_picture}`}
alt={card.assignee.username}
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 className="flex items-center gap-1">
<AccountIcon size="1rem" className="text-gray-400" />
<span className="text-xs text-gray-500 dark:text-gray-400">
{card.assignee.full_name || card.assignee.username}
</span>
</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>
{/* Labels */}
{card.labels && (
<div className="mt-1.5 flex flex-wrap gap-1">

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { departmentsAPI, kanbanAPI } from '../../services/api';
import ConfirmDialog from '../Common/ConfirmDialog';
import type { KanbanCard, User, Department, KanbanChecklistWithItems } from '../../types';
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 [showChecklistForm, setShowChecklistForm] = useState(false);
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(() => {
loadAvailableUsers();
@ -105,11 +119,14 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
// Collect all users from these departments
const userSet = new Map<number, User>();
for (const _dept of departments) {
// This is a simplified approach - in a real app you'd have an endpoint to get department users
// For now, we'll just include the current user and maybe add more logic later
if (user) {
userSet.set(user.id, user);
for (const dept of departments) {
try {
const deptUsers: User[] = await departmentsAPI.getUsers(dept.id);
for (const deptUser of deptUsers) {
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
const autoSaveTitle = () => {
if (title.trim() !== card.title) {
@ -133,9 +165,9 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
}
};
const autoSaveAssignee = () => {
if (assigneeId !== card.assignee_id) {
onUpdate(card.id, { assignee_id: assigneeId });
const autoSavePriority = () => {
if (priority !== (card.priority || 'medium')) {
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 lbls = labels.trim() || undefined;
if (lbls !== (card.labels || undefined)) {
@ -250,227 +276,619 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
useEffect(() => {
loadAvailableUsers();
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 (
<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-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div
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 */}
<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
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
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..."
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
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-1"
>
<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>
{/* 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={onClose}
className="text-gray-400 hover:text-gray-600 p-1"
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'
}`}
>
<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>
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 */}
<div className="p-6 space-y-6">
{/* Description */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
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"
rows={4}
placeholder="Beschreibung hinzufügen..."
/>
</div>
<div className="p-4 bg-gray-50 dark:bg-gray-800">
{/* Overview Tab - Details & Einstellungen */}
{activeTab === 'overview' && (
<div className="space-y-3">
{/* Description */}
<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">
Beschreibung
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
onBlur={autoSaveDescription}
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}
placeholder="Beschreibung hinzufügen..."
/>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priorität
</label>
<select
value={priority}
onChange={(e) => {
setPriority(e.target.value as 'low' | 'medium' | 'high');
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"
>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select>
</div>
{/* Due Date */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Fälligkeitsdatum
</label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
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"
/>
</div>
{/* Assignee */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Zugewiesen an
</label>
<select
value={assigneeId || ''}
onChange={(e) => {
setAssigneeId(e.target.value ? parseInt(e.target.value) : undefined);
autoSaveAssignee();
}}
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"
>
<option value="">Nicht zugewiesen</option>
{availableUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.username} {user.full_name && `(${user.full_name})`}
</option>
))}
</select>
</div>
{/* Labels */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Labels (kommagetrennt)
</label>
<input
type="text"
value={labels}
onChange={(e) => setLabels(e.target.value)}
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"
placeholder="z.B. bug, feature, urgent"
/>
</div>
{/* Checklists */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Checklisten
</label>
<button
onClick={() => setShowChecklistForm(true)}
className="px-3 py-1 text-sm bg-green-500 text-white rounded hover:bg-green-600"
>
+ Checkliste
</button>
</div>
{/* New Checklist Form */}
{showChecklistForm && (
<div className="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex gap-2">
<input
type="text"
value={newChecklistTitle}
onChange={(e) => setNewChecklistTitle(e.target.value)}
placeholder="Checklisten-Titel eingeben..."
className="flex-1 p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
onKeyPress={(e) => e.key === 'Enter' && handleCreateChecklist()}
/>
<button
onClick={handleCreateChecklist}
className="px-3 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Hinzufügen
</button>
<button
onClick={() => {
setShowChecklistForm(false);
setNewChecklistTitle('');
{/* 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Priorität
</label>
<select
value={priority}
onChange={(e) => {
setPriority(e.target.value as 'low' | 'medium' | 'high');
autoSavePriority();
}}
className="px-3 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
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"
>
Abbrechen
</button>
<option value="low">Niedrig</option>
<option value="medium">Mittel</option>
<option value="high">Hoch</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Fälligkeitsdatum
</label>
<input
type="date"
value={dueDate}
onChange={(e) => setDueDate(e.target.value)}
onBlur={autoSaveDueDate}
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>
)}
{/* Checklists */}
<div className="space-y-4">
{checklists.map((checklist) => (
<div key={checklist.id} className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h4 className="font-medium text-gray-900 dark:text-white">{checklist.title}</h4>
<button
onClick={() => handleDeleteChecklist(checklist.id)}
className="text-gray-400 hover:text-red-500 p-1"
title="Checkliste 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>
{/* 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Zugewiesen an
</label>
<select
value={assigneeId || ''}
onChange={(e) => {
const newAssigneeId = e.target.value ? parseInt(e.target.value) : undefined;
setAssigneeId(newAssigneeId);
// Check and save immediately with new value
if (newAssigneeId !== card.assignee_id) {
onUpdate(card.id, { assignee_id: newAssigneeId });
}
}}
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>
{availableUsers.map((user) => (
<option key={user.id} value={user.id}>
{user.username} {user.full_name && `(${user.full_name})`}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Labels (kommagetrennt)
</label>
<input
type="text"
value={labels}
onChange={(e) => setLabels(e.target.value)}
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 focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
placeholder="z.B. bug, feature, urgent"
/>
</div>
</div>
{/* Checklists */}
<div>
<div className="flex items-center justify-between mb-3">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Checklisten
</label>
<button
onClick={() => setShowChecklistForm(true)}
className="px-3 py-1 text-sm bg-green-500 text-white rounded hover:bg-green-600"
>
+ Checkliste
</button>
</div>
{/* New Checklist Form */}
{showChecklistForm && (
<div className="mb-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div className="flex gap-2">
<input
type="text"
value={newChecklistTitle}
onChange={(e) => setNewChecklistTitle(e.target.value)}
placeholder="Checklisten-Titel eingeben..."
className="flex-1 p-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
onKeyPress={(e) => e.key === 'Enter' && handleCreateChecklist()}
/>
<button
onClick={handleCreateChecklist}
className="px-3 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
Hinzufügen
</button>
<button
onClick={() => {
setShowChecklistForm(false);
setNewChecklistTitle('');
}}
className="px-3 py-2 bg-gray-500 text-white rounded hover:bg-gray-600"
>
Abbrechen
</button>
</div>
</div>
)}
{/* Checklist Items */}
<div className="space-y-2">
{checklist.items.map((item) => (
<div key={item.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={item.is_completed}
onChange={(e) => handleToggleChecklistItem(item.id, e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<span className={`flex-1 text-sm ${item.is_completed ? 'line-through text-gray-500' : 'text-gray-900 dark:text-white'}`}>
{item.title}
</span>
{/* Checklists */}
<div className="space-y-4">
{checklists.map((checklist) => (
<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">
<h4 className="font-medium text-gray-900 dark:text-white">{checklist.title}</h4>
<button
onClick={() => handleDeleteChecklistItem(item.id)}
onClick={() => handleDeleteChecklist(checklist.id)}
className="text-gray-400 hover:text-red-500 p-1"
title="Aufgabe löschen"
title="Checkliste 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="M6 18L18 6M6 6l12 12" />
<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>
))}
{/* Add new item */}
<AddChecklistItemForm checklistId={checklist.id} onAdd={handleCreateChecklistItem} />
</div>
{/* Checklist Items */}
<div className="space-y-2">
{checklist.items.map((item) => (
<div key={item.id} className="flex items-center gap-2">
<input
type="checkbox"
checked={item.is_completed}
onChange={(e) => handleToggleChecklistItem(item.id, e.target.checked)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-gray-700 dark:border-gray-600"
/>
<span className={`flex-1 text-sm ${item.is_completed ? 'line-through text-gray-500' : 'text-gray-900 dark:text-white'}`}>
{item.title}
</span>
<button
onClick={() => handleDeleteChecklistItem(item.id)}
className="text-gray-400 hover:text-red-500 p-1"
title="Aufgabe 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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
))}
{/* Progress */}
<div className="mt-3 text-xs text-gray-500">
{checklist.items.filter(item => item.is_completed).length} von {checklist.items.length} Aufgaben erledigt
</div>
{/* Add new item */}
<AddChecklistItemForm checklistId={checklist.id} onAdd={handleCreateChecklistItem} />
</div>
{/* Progress */}
<div className="mt-3 text-xs text-gray-500">
{checklist.items.filter(item => item.is_completed).length} von {checklist.items.length} Aufgaben erledigt
</div>
</div>
))}
{checklists.length === 0 && !showChecklistForm && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
Keine Checklisten vorhanden. Klicke auf "+ Checkliste" um eine hinzuzufügen.
</div>
)}
</div>
))}
</div>
</div>
)}
{checklists.length === 0 && !showChecklistForm && (
{/* 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 Checklisten vorhanden. Klicke auf "+ Checkliste" um eine hinzuzufügen.
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>
</div>
)}
{/* 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')}
{card.updated_at !== card.created_at && (
<span className="ml-4">
@ -480,6 +898,21 @@ const KanbanCardModal: React.FC<KanbanCardModalProps> = ({
</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>
);
};

View File

@ -1,5 +1,5 @@
import React, { useState } from 'react';
import type { KanbanColumnWithCards, KanbanCard } from '../../types';
import type { KanbanColumnWithCards, KanbanCardExtended } from '../../types';
import KanbanCardComponent from './KanbanCard';
interface KanbanColumnProps {
@ -9,7 +9,7 @@ interface KanbanColumnProps {
onCreateCard: (columnId: number) => void;
onDeleteCard: (cardId: number) => void;
onMoveCard: (cardId: number, targetColumnId: number, newPosition: number) => void;
onCardClick: (card: KanbanCard) => void;
onCardClick: (card: KanbanCardExtended) => void;
}
const KanbanColumn: React.FC<KanbanColumnProps> = ({
@ -87,8 +87,8 @@ const KanbanColumn: React.FC<KanbanColumnProps> = ({
return (
<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 ${
draggedOver ? 'ring-1 ring-blue-500 bg-blue-50 dark:bg-blue-900/10' : ''
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/50 dark:bg-blue-900/20' : ''
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -133,20 +133,31 @@ const KanbanColumn: React.FC<KanbanColumnProps> = ({
{column.cards.length}
</span>
</div>
{(() => {
const defaultColumns = ['ToDo', 'In Progress', 'Waiting', 'Done'];
return !defaultColumns.includes(column.name) && (
<button
onClick={() => onDeleteColumn(column.id)}
className="text-gray-400 hover:text-red-500 p-1"
title="Spalte 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 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'];
return !defaultColumns.includes(column.name) && (
<button
onClick={() => onDeleteColumn(column.id)}
className="text-gray-400 hover:text-red-500 p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
title="Spalte 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>
{/* Cards */}

View File

@ -6,6 +6,7 @@ interface KanbanSidebarProps {
departments: Department[];
selectedChannel: Channel | null;
onSelectChannel: (channel: Channel) => void;
onDeleteChannel?: (channel: Channel) => void;
}
const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
@ -13,7 +14,9 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
departments,
selectedChannel,
onSelectChannel,
onDeleteChannel,
}) => {
// Group channels by department
const channelsByDept = channels.reduce((acc, channel) => {
if (!acc[channel.department_id]) {
@ -23,39 +26,61 @@ const KanbanSidebar: React.FC<KanbanSidebarProps> = ({
return acc;
}, {} 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 (
<div className="w-52 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="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-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>
</div>
<div className="flex-1 overflow-y-auto">
{departments.map((dept) => (
<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}
</div>
{channelsByDept[dept.id]?.map((channel) => (
<button
key={channel.id}
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 ${
selectedChannel?.id === channel.id
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100 border-r-2 border-blue-500'
: 'text-gray-700 dark:text-gray-300'
}`}
>
<div className="flex items-center">
<span className="text-gray-500 dark:text-gray-400 mr-2">#</span>
<span className="truncate">{channel.name}</span>
</div>
{channel.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{channel.description}
<div key={channel.id} className="relative group">
<button
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 ${
selectedChannel?.id === channel.id
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100 border-r-2 border-blue-500'
: 'text-gray-700 dark:text-gray-300'
}`}
>
<div className="flex items-center">
<span className="text-gray-500 dark:text-gray-400 mr-1">#</span>
<span className="truncate">{channel.name}</span>
</div>
{channel.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{channel.description}
</div>
)}
</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>
)}
</button>
</div>
))}
</div>
))}

View File

@ -1,9 +1,10 @@
import React, { useEffect, useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
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 { isAdmin } from '../../types';
import ToastContainer from '../ToastContainer';
const Layout: React.FC = () => {
@ -49,7 +50,7 @@ const Layout: React.FC = () => {
return;
}
if (user.is_admin) {
if (isAdmin(user)) {
if (isMounted) {
setHasSnippetAccess(true);
}
@ -76,17 +77,22 @@ const Layout: React.FC = () => {
return () => {
isMounted = false;
};
}, [user?.id, user?.is_admin]);
}, [user?.id, isAdmin(user)]);
return (
<div className="h-screen flex flex-col bg-gray-50 dark:bg-gray-900">
{/* Header */}
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-3 py-2">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<h1 className="text-base font-bold text-gray-900 dark:text-white">
Collabrix
</h1>
<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">
<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">
Collabrix
</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">
<Link
to="/"
@ -98,7 +104,17 @@ const Layout: React.FC = () => {
>
Chat
</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
to="/snippets"
className={`px-3 py-1.5 text-sm rounded ${
@ -110,32 +126,10 @@ const Layout: React.FC = () => {
Snippets
</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>
</div>
<div className="flex items-center space-x-3">
<div className="absolute right-3 top-2 flex items-center space-x-3">
<ToastContainer />
<div className="flex items-center space-x-2.5 relative user-menu-container">
<button
@ -144,7 +138,7 @@ const Layout: React.FC = () => {
>
{user?.profile_picture ? (
<img
src={`http://localhost:8000/${user.profile_picture}`}
src={getApiUrl(user.profile_picture)}
alt={user.username}
className="w-8 h-8 rounded-full object-cover"
/>
@ -154,7 +148,7 @@ const Layout: React.FC = () => {
</div>
)}
<span className="text-sm font-medium text-gray-900 dark:text-white">
{user?.username}
{user?.full_name || user?.username}
</span>
<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" />
@ -170,6 +164,15 @@ const Layout: React.FC = () => {
>
Profil
</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
onClick={() => {
toggleTheme();

View File

@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useAuth } from '../../contexts/AuthContext';
import { authAPI, departmentsAPI } from '../../services/api';
import { authAPI, departmentsAPI, getApiUrl } from '../../services/api';
import type { Department } from '../../types';
const ProfilePage: React.FC = () => {
@ -147,14 +147,14 @@ const ProfilePage: React.FC = () => {
<div className="flex flex-col items-center">
{profilePicture ? (
<img
src={`http://localhost:8000/${profilePicture}`}
src={getApiUrl(profilePicture)}
alt="Profile"
className="w-32 h-32 rounded-full object-cover mb-4"
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';
}}
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">
@ -177,7 +177,7 @@ const ProfilePage: React.FC = () => {
{user.username}
</h2>
<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>
</div>

View File

@ -38,7 +38,7 @@ const LANGUAGES = [
interface SnippetEditorProps {
snippet: Snippet | null;
onSave: () => void;
onSave: (createdSnippet?: Snippet) => void;
onCancel: () => void;
}
@ -98,13 +98,14 @@ const SnippetEditor: React.FC<SnippetEditorProps> = ({ snippet, onSave, onCancel
department_id: visibility === 'department' ? departmentId : undefined,
};
let result;
if (snippet) {
await snippetsAPI.update(snippet.id, data);
result = await snippetsAPI.update(snippet.id, data);
} else {
await snippetsAPI.create(data);
result = await snippetsAPI.create(data);
}
onSave();
onSave(result);
} catch (error: any) {
console.error('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);
};
const handleSave = async () => {
const handleSave = async (createdSnippet?: Snippet) => {
setShowEditor(false);
setEditingSnippet(null);
await loadSnippets();
if (createdSnippet) {
// Add the new snippet to the existing list
setSnippets(prev => [createdSnippet, ...prev]);
} else {
// For updates, reload all snippets
await loadSnippets();
}
};
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 { authAPI } from '../services/api';
import { useToast } from './ToastContext';
import type { User, LoginRequest, RegisterRequest } from '../types';
interface AuthContextType {
@ -10,6 +11,7 @@ interface AuthContextType {
logout: () => void;
refreshUser: () => Promise<void>;
isAuthenticated: boolean;
presenceWs: WebSocket | null;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
@ -17,13 +19,35 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [presenceWs, setPresenceWs] = useState<WebSocket | null>(null);
const { addToast } = useToast();
useEffect(() => {
if (token) {
if (token && window.location.pathname !== '/login' && window.location.pathname !== '/register') {
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]);
// 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 () => {
try {
const userData = await authAPI.getCurrentUser();
@ -35,9 +59,64 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
root.classList.remove('light', 'dark');
root.classList.add(userData.theme);
}
// Open presence connection when user is loaded
openPresenceConnection();
} catch (error) {
// If loading user fails, clear token and user
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);
localStorage.setItem('token', response.access_token);
setToken(response.access_token);
// Load user immediately after login
await loadUser();
};
const register = async (data: RegisterRequest) => {
@ -54,6 +135,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
};
const logout = () => {
// Close presence connection before logging out
closePresenceConnection();
localStorage.removeItem('token');
localStorage.removeItem('lastVisitedPath');
sessionStorage.removeItem('routeRestored');
@ -80,6 +163,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
logout,
refreshUser,
isAuthenticated: !!token && !!user,
presenceWs,
}}
>
{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 { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
import { UserStatusProvider } from './contexts/UserStatusContext';
import { UnreadMessagesProvider } from './contexts/UnreadMessagesContext';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ThemeProvider>
<AuthProvider>
<ToastProvider>
<App />
</ToastProvider>
</AuthProvider>
<ToastProvider>
<AuthProvider>
<UserStatusProvider>
<UnreadMessagesProvider>
<App />
</UnreadMessagesProvider>
</UserStatusProvider>
</AuthProvider>
</ToastProvider>
</ThemeProvider>
</StrictMode>
);

View File

@ -24,6 +24,22 @@ api.interceptors.request.use((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 = {
login: async (data: LoginRequest): Promise<AuthResponse> => {
const response = await api.post('/auth/login', data);
@ -68,6 +84,11 @@ export const departmentsAPI = {
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 }) => {
const response = await api.post('/departments/', data);
return response.data;
@ -89,6 +110,11 @@ export const channelsAPI = {
const response = await api.post('/channels/', data);
return response.data;
},
delete: async (channelId: number) => {
const response = await api.delete(`/channels/${channelId}`);
return response.data;
},
};
export const messagesAPI = {
@ -237,10 +263,26 @@ export const directMessagesAPI = {
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);
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 = {
@ -255,6 +297,11 @@ export const kanbanAPI = {
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 }) => {
const response = await api.put(`/kanban/boards/${boardId}`, data);
return response.data;
@ -309,6 +356,11 @@ export const kanbanAPI = {
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) => {
const response = await api.put(`/kanban/cards/${cardId}/move`, null, {
params: { target_column_id: targetColumnId, new_position: newPosition }
@ -357,6 +409,180 @@ export const kanbanAPI = {
const response = await api.delete(`/kanban/checklist-items/${itemId}`);
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;

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 {
id: number;
username: string;
@ -6,7 +20,7 @@ export interface User {
profile_picture?: string;
theme?: string;
is_active: boolean;
is_admin: boolean;
role: UserRole;
created_at: string;
}
@ -43,6 +57,7 @@ export interface Message {
id: number;
content: string;
sender_username: string;
sender_full_name?: string;
};
is_deleted?: boolean;
deleted?: boolean;
@ -154,9 +169,15 @@ export interface KanbanCard {
due_date?: string;
priority?: 'low' | 'medium' | 'high';
labels?: string;
estimated_time?: number;
actual_time?: number;
created_at: string;
updated_at: string;
assignee?: User;
attachments_count: number;
checklists_count: number;
comments_count: number;
is_archived?: boolean;
}
export interface KanbanBoardWithColumns extends KanbanBoard {
@ -164,7 +185,7 @@ export interface KanbanBoardWithColumns extends KanbanBoard {
}
export interface KanbanColumnWithCards extends KanbanColumn {
cards: KanbanCard[];
cards: KanbanCardExtended[];
}
// Checklist Types
@ -195,3 +216,89 @@ export interface KanbanCardWithChecklists extends KanbanCard {
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/ {
rewrite ^/api/(.*) /$1 break;
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_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@ -101,6 +103,8 @@ server {
# Auth endpoints
location /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_set_header Host $host;
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 ""
npm run dev
npm run build